Merge branch 'refactor/#20-1'

# Conflicts:
#	src/main/kotlin/roomescape/auth/docs/AuthAPI.kt
#	src/main/kotlin/roomescape/auth/service/AuthService.kt
#	src/main/kotlin/roomescape/member/business/MemberService.kt
#	src/main/kotlin/roomescape/member/web/MemberController.kt
#	src/main/kotlin/roomescape/member/web/MemberDTO.kt
#	src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt
This commit is contained in:
이상진 2025-07-28 10:33:37 +09:00
commit 6149b8a563
86 changed files with 1461 additions and 975 deletions

View File

@ -44,6 +44,11 @@ dependencies {
// Jwt // Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6") implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Kotlin // Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

View File

@ -1,4 +1,4 @@
package roomescape.auth.service package roomescape.auth.business
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -9,6 +9,7 @@ import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginResponse import roomescape.auth.web.LoginResponse
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
@ -17,40 +18,50 @@ private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class AuthService( class AuthService(
private val memberService: MemberService, private val memberService: MemberService,
private val jwtHandler: JwtHandler private val jwtHandler: JwtHandler,
) { ) {
fun login(request: LoginRequest): LoginResponse { fun login(request: LoginRequest): LoginResponse {
val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED) { log.debug { "[AuthService.login] 로그인 시작: email=${request.email}" }
val params = "email=${request.email}, password=${request.password}"
val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED, params, "login") {
memberService.findByEmailAndPassword(request.email, request.password) memberService.findByEmailAndPassword(request.email, request.password)
} }
val accessToken: String = jwtHandler.createToken(member.id!!) val accessToken: String = jwtHandler.createToken(member.id!!)
return LoginResponse(accessToken) return LoginResponse(accessToken)
.also { log.info { "[AuthService.login] 로그인 완료: memberId=${member.id}" } }
} }
fun checkLogin(memberId: Long): LoginCheckResponse { fun checkLogin(memberId: Long): LoginCheckResponse {
val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.UNIDENTIFIABLE_MEMBER) { log.debug { "[AuthService.checkLogin] 로그인 확인 시작: memberId=$memberId" }
memberService.findById(memberId) val member: MemberEntity =
} fetchMemberOrThrow(AuthErrorCode.UNIDENTIFIABLE_MEMBER, "memberId=$memberId", "checkLogin") {
memberService.findById(memberId)
}
return LoginCheckResponse(member.name, member.role.name) return LoginCheckResponse(member.name, member.role.name)
.also { log.info { "[AuthService.checkLogin] 로그인 확인 완료: memberId=$memberId" } }
}
fun logout(memberId: Long) {
log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" }
} }
private fun fetchMemberOrThrow( private fun fetchMemberOrThrow(
errorCode: AuthErrorCode, errorCode: AuthErrorCode,
block: () -> MemberEntity params: String,
calledBy: String,
block: () -> MemberEntity,
): MemberEntity { ): MemberEntity {
try { try {
log.debug { "[AuthService.$calledBy] 회원 조회 시작: $params" }
return block() return block()
} catch (_: Exception) { } catch (e: Exception) {
if (e !is RoomescapeException) {
log.warn(e) { "[AuthService.$calledBy] 회원 조회 실패: $params" }
}
throw AuthException(errorCode) throw AuthException(errorCode)
} }
} }
fun logout(memberId: Long?) {
if (memberId != null) {
log.info { "requested logout for $memberId" }
}
}
} }

View File

@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginResponse import roomescape.auth.web.LoginResponse
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -36,6 +37,7 @@ interface AuthAPI {
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> ): ResponseEntity<CommonApiResponse<LoginCheckResponse>>
@LoginRequired
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."),

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class AuthErrorCode( enum class AuthErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String, override val message: String,
) : ErrorCode { ) : ErrorCode {
TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A001", "인증 토큰이 없어요."), TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A001", "인증 토큰이 없어요."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰이에요."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰이에요."),

View File

@ -3,6 +3,6 @@ package roomescape.auth.exception
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class AuthException( class AuthException(
override val errorCode: AuthErrorCode, override val errorCode: AuthErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -12,11 +12,11 @@ import javax.crypto.SecretKey
@Component @Component
class JwtHandler( class JwtHandler(
@Value("\${security.jwt.token.secret-key}") @Value("\${security.jwt.token.secret-key}")
private val secretKeyString: String, private val secretKeyString: String,
@Value("\${security.jwt.token.ttl-seconds}") @Value("\${security.jwt.token.ttl-seconds}")
private val tokenTtlSeconds: Long private val tokenTtlSeconds: Long
) { ) {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
@ -25,22 +25,22 @@ class JwtHandler(
val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds) val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds)
return Jwts.builder() return Jwts.builder()
.claim(MEMBER_ID_CLAIM_KEY, memberId) .claim(MEMBER_ID_CLAIM_KEY, memberId)
.issuedAt(date) .issuedAt(date)
.expiration(accessTokenExpiredAt) .expiration(accessTokenExpiredAt)
.signWith(secretKey) .signWith(secretKey)
.compact() .compact()
} }
fun getMemberIdFromToken(token: String?): Long { fun getMemberIdFromToken(token: String?): Long {
try { try {
return Jwts.parser() return Jwts.parser()
.verifyWith(secretKey) .verifyWith(secretKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.payload .payload
.get(MEMBER_ID_CLAIM_KEY, Number::class.java) .get(MEMBER_ID_CLAIM_KEY, Number::class.java)
.toLong() .toLong()
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
} catch (_: ExpiredJwtException) { } catch (_: ExpiredJwtException) {

View File

@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import roomescape.auth.docs.AuthAPI import roomescape.auth.docs.AuthAPI
import roomescape.auth.service.AuthService import roomescape.auth.business.AuthService
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse

View File

@ -18,24 +18,24 @@ class JacksonConfig {
@Bean @Bean
fun objectMapper(): ObjectMapper = ObjectMapper() fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule()) .registerModule(javaTimeModule())
.registerModule(kotlinModule()) .registerModule(kotlinModule())
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer( .addSerializer(
LocalDate::class.java, LocalDate::class.java,
LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE) LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)
) )
.addDeserializer( .addDeserializer(
LocalDate::class.java, LocalDate::class.java,
LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE) LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)
) )
.addSerializer( .addSerializer(
LocalTime::class.java, LocalTime::class.java,
LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm"))
) )
.addDeserializer( .addDeserializer(
LocalTime::class.java, LocalTime::class.java,
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))
) as JavaTimeModule ) as JavaTimeModule
} }

View File

@ -15,8 +15,9 @@ class SwaggerConfig {
private fun apiInfo(): Info { private fun apiInfo(): Info {
return Info() return Info()
.title("방탈출 예약 API 문서") .title("방탈출 예약 API 문서")
.description(""" .description(
"""
## API 테스트는 '1. 인증 / 인가 API' '/login' 통해 로그인 사용해주세요. ## API 테스트는 '1. 인증 / 인가 API' '/login' 통해 로그인 사용해주세요.
### 테스트시 로그인 가능한 계정 정보 ### 테스트시 로그인 가능한 계정 정보
@ -70,7 +71,8 @@ class SwaggerConfig {
- 8 ~ 10: 예약 대기 상태 - 8 ~ 10: 예약 대기 상태
""".trimIndent()) """.trimIndent()
.version("1.0.0") )
.version("1.0.0")
} }
} }

View File

@ -9,8 +9,8 @@ import roomescape.auth.web.support.MemberIdResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(
private val memberIdResolver: MemberIdResolver, private val memberIdResolver: MemberIdResolver,
private val authInterceptor: AuthInterceptor private val authInterceptor: AuthInterceptor
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {

View File

@ -5,15 +5,15 @@ import roomescape.common.exception.ErrorCode
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
data class CommonApiResponse<T>( data class CommonApiResponse<T>(
val data: T? = null, val data: T? = null,
) )
data class CommonErrorResponse( data class CommonErrorResponse(
val code: String, val code: String,
val message: String val message: String
) { ) {
constructor(errorCode: ErrorCode, message: String = errorCode.message) : this( constructor(errorCode: ErrorCode, message: String = errorCode.message) : this(
code = errorCode.errorCode, code = errorCode.errorCode,
message = message message = message
) )
} }

View File

@ -3,18 +3,18 @@ package roomescape.common.exception
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
enum class CommonErrorCode( enum class CommonErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String, override val message: String,
) : ErrorCode { ) : ErrorCode {
INVALID_INPUT_VALUE( INVALID_INPUT_VALUE(
httpStatus = HttpStatus.BAD_REQUEST, httpStatus = HttpStatus.BAD_REQUEST,
errorCode = "C001", errorCode = "C001",
message = "요청 값이 잘못되었어요." message = "요청 값이 잘못되었어요."
), ),
UNEXPECTED_SERVER_ERROR( UNEXPECTED_SERVER_ERROR(
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR, httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
errorCode = "C999", errorCode = "C999",
message = "서버에 예상치 못한 오류가 발생했어요. 관리자에게 문의해주세요.", message = "서버에 예상치 못한 오류가 발생했어요. 관리자에게 문의해주세요.",
), ),
} }

View File

@ -11,48 +11,46 @@ import roomescape.common.dto.response.CommonErrorResponse
@RestControllerAdvice @RestControllerAdvice
class ExceptionControllerAdvice( class ExceptionControllerAdvice(
private val logger: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
) { ) {
@ExceptionHandler(value = [RoomescapeException::class]) @ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> { fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}" }
val errorCode: ErrorCode = e.errorCode val errorCode: ErrorCode = e.errorCode
return ResponseEntity return ResponseEntity
.status(errorCode.httpStatus) .status(errorCode.httpStatus)
.body(CommonErrorResponse(errorCode, e.message)) .body(CommonErrorResponse(errorCode, e.message))
} }
@ExceptionHandler(value = [HttpMessageNotReadableException::class]) @ExceptionHandler(value = [HttpMessageNotReadableException::class])
fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<CommonErrorResponse> { fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}" } log.debug { "message: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
return ResponseEntity return ResponseEntity
.status(errorCode.httpStatus) .status(errorCode.httpStatus)
.body(CommonErrorResponse(errorCode)) .body(CommonErrorResponse(errorCode))
} }
@ExceptionHandler(value = [MethodArgumentNotValidException::class]) @ExceptionHandler(value = [MethodArgumentNotValidException::class])
fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<CommonErrorResponse> { fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<CommonErrorResponse> {
val message: String = e.bindingResult.allErrors val message: String = e.bindingResult.allErrors
.mapNotNull { it.defaultMessage } .mapNotNull { it.defaultMessage }
.joinToString(", ") .joinToString(", ")
logger.error(e) { "message: $message" } log.debug { "message: $message" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
return ResponseEntity return ResponseEntity
.status(errorCode.httpStatus) .status(errorCode.httpStatus)
.body(CommonErrorResponse(errorCode)) .body(CommonErrorResponse(errorCode))
} }
@ExceptionHandler(value = [Exception::class]) @ExceptionHandler(value = [Exception::class])
fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> { fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}" } log.error(e) { "message: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
return ResponseEntity return ResponseEntity
.status(errorCode.httpStatus) .status(errorCode.httpStatus)
.body(CommonErrorResponse(errorCode)) .body(CommonErrorResponse(errorCode))
} }
} }

View File

@ -1,6 +1,6 @@
package roomescape.common.exception package roomescape.common.exception
open class RoomescapeException( open class RoomescapeException(
open val errorCode: ErrorCode, open val errorCode: ErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RuntimeException(message) ) : RuntimeException(message)

View File

@ -0,0 +1,74 @@
package roomescape.common.log
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import java.nio.charset.StandardCharsets
private val log = KotlinLogging.logger {}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class LoggingFilter(
private val objectMapper: ObjectMapper
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val cachedRequest = ContentCachingRequestWrapper(request)
val cachedResponse = ContentCachingResponseWrapper(response)
val startTime = System.currentTimeMillis()
filterChain.doFilter(cachedRequest, cachedResponse)
val duration = System.currentTimeMillis() - startTime
logAPISummary(cachedRequest, cachedResponse, duration)
cachedResponse.copyBodyToResponse()
}
private fun logAPISummary(
request: ContentCachingRequestWrapper,
response: ContentCachingResponseWrapper,
duration: Long
) {
val payload = linkedMapOf<String, Any>(
"type" to "API_LOG",
"method" to request.method,
"url" to request.requestURL.toString(),
)
request.queryString?.let { payload["query_params"] = it }
payload["remote_ip"] = request.remoteAddr
payload["status_code"] = response.status
payload["duration_ms"] = duration
if (log.isDebugEnabled()) {
request.contentAsByteArray.takeIf { it.isNotEmpty() }
?.let { payload["request_body"] = parseContent(it) }
response.contentAsByteArray.takeIf { it.isNotEmpty() }
?.let { payload["response_body"] = parseContent(it) }
}
log.info { objectMapper.writeValueAsString(payload) }
}
private fun parseContent(content: ByteArray): Any {
return try {
objectMapper.readValue(content, Map::class.java)
} catch (_: Exception) {
String(content, StandardCharsets.UTF_8)
}
}
}

View File

@ -0,0 +1,73 @@
package roomescape.common.log
import ch.qos.logback.classic.pattern.MessageConverter
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TextNode
import roomescape.common.config.JacksonConfig
private const val MASK: String = "****"
private val SENSITIVE_KEYS = setOf("password", "accessToken")
class RoomescapeLogMaskingConverter(
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
) : MessageConverter() {
override fun convert(event: ILoggingEvent): String {
val message: String = event.formattedMessage
return if (isJsonString(message)) {
maskedJsonString(message)
} else {
maskedPlainMessage(message)
}
}
private fun isJsonString(message: String): Boolean {
val trimmed = message.trim()
return trimmed.startsWith("{") && trimmed.endsWith("}")
}
private fun maskedJsonString(body: String): String = objectMapper.readValue(body, JsonNode::class.java)
.apply { maskRecursive(this) }
.toString()
private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*=\\s*)(\\S+)")
return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1]
val delimiter = matchResult.groupValues[2]
"${key}${delimiter}${MASK}"
}
}
private fun maskRecursive(node: JsonNode?) {
node?.forEachEntry { key, childNode ->
when {
childNode.isValueNode -> {
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, MASK)
}
childNode.isObject -> maskRecursive(childNode)
childNode.isArray -> {
val arrayNode = childNode as ArrayNode
val originSize = arrayNode.size()
if (originSize > 1) {
val first = arrayNode.first()
arrayNode.removeAll()
arrayNode.add(first)
arrayNode.add(TextNode("(...logged only first of $originSize elements)"))
}
arrayNode.forEach { maskRecursive(it) }
}
}
}
}
}

View File

@ -1,5 +1,6 @@
package roomescape.member.business package roomescape.member.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -10,26 +11,37 @@ import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.* import roomescape.member.web.*
private val log = KotlinLogging.logger {}
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class MemberService( class MemberService(
private val memberRepository: MemberRepository private val memberRepository: MemberRepository,
) { ) {
fun findMembers(): MemberRetrieveListResponse = MemberRetrieveListResponse( fun findMembers(): MemberRetrieveListResponse {
members = memberRepository.findAll().map { it.toRetrieveResponse() } log.debug { "[MemberService.findMembers] 회원 조회 시작" }
)
fun findById(memberId: Long): MemberEntity = fetchOrThrow { return memberRepository.findAll()
memberRepository.findByIdOrNull(memberId) .also { log.info { "[MemberService.findMembers] 회원 ${it.size}명 조회 완료" } }
.toRetrieveListResponse()
} }
fun findByEmailAndPassword(email: String, password: String): MemberEntity = fetchOrThrow { fun findById(memberId: Long): MemberEntity {
memberRepository.findByEmailAndPassword(email, password) return fetchOrThrow("findById", "memberId=$memberId") {
memberRepository.findByIdOrNull(memberId)
}
}
fun findByEmailAndPassword(email: String, password: String): MemberEntity {
return fetchOrThrow("findByEmailAndPassword", "email=$email, password=$password") {
memberRepository.findByEmailAndPassword(email, password)
}
} }
@Transactional @Transactional
fun create(request: SignupRequest): SignupResponse { fun createMember(request: SignupRequest): SignupResponse {
memberRepository.findByEmail(request.email)?.let { memberRepository.findByEmail(request.email)?.let {
log.info { "[MemberService.createMember] 회원가입 실패(이메일 중복): email=${request.email}" }
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL) throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
} }
@ -39,10 +51,18 @@ class MemberService(
password = request.password, password = request.password,
role = Role.MEMBER role = Role.MEMBER
) )
return memberRepository.save(member).toSignupResponse() return memberRepository.save(member).toSignupResponse()
.also { log.info { "[MemberService.create] 회원가입 완료: email=${request.email} memberId=${it.id}" } }
} }
private fun fetchOrThrow(block: () -> MemberEntity?): MemberEntity { private fun fetchOrThrow(calledBy: String, params: String, block: () -> MemberEntity?): MemberEntity {
return block() ?: throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) log.debug { "[MemberService.$calledBy] 회원 조회 시작: params=$params" }
return block()
?.also { log.info { "[MemberService.$calledBy] 회원 조회 완료: memberId=${it.id}" } }
?: run {
log.info { "[MemberService.$calledBy] 회원 조회 실패: $params" }
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
}
} }
} }

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class MemberErrorCode( enum class MemberErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M002", "이미 가입된 이메일이에요.") DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M002", "이미 가입된 이메일이에요.")

View File

@ -3,6 +3,6 @@ package roomescape.member.exception
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class MemberException( class MemberException(
override val errorCode: MemberErrorCode, override val errorCode: MemberErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -5,15 +5,15 @@ import jakarta.persistence.*
@Entity @Entity
@Table(name = "members") @Table(name = "members")
class MemberEntity( class MemberEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
var name: String, var name: String,
var email: String, var email: String,
var password: String, var password: String,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var role: Role var role: Role
) { ) {
fun isAdmin(): Boolean = role == Role.ADMIN fun isAdmin(): Boolean = role == Role.ADMIN
} }

View File

@ -17,7 +17,7 @@ class MemberController(
@PostMapping("/members") @PostMapping("/members")
override fun signup(@RequestBody request: SignupRequest): ResponseEntity<CommonApiResponse<SignupResponse>> { override fun signup(@RequestBody request: SignupRequest): ResponseEntity<CommonApiResponse<SignupResponse>> {
val response: SignupResponse = memberService.create(request) val response: SignupResponse = memberService.createMember(request)
return ResponseEntity.created(URI.create("/members/${response.id}")) return ResponseEntity.created(URI.create("/members/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
} }

View File

@ -16,6 +16,10 @@ data class MemberRetrieveResponse(
val name: String val name: String
) )
fun List<MemberEntity>.toRetrieveListResponse(): MemberRetrieveListResponse = MemberRetrieveListResponse(
members = this.map { it.toRetrieveResponse() }
)
data class MemberRetrieveListResponse( data class MemberRetrieveListResponse(
val members: List<MemberRetrieveResponse> val members: List<MemberRetrieveResponse>
) )

View File

@ -1,5 +1,6 @@
package roomescape.payment.business package roomescape.payment.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentErrorCode
@ -16,85 +17,127 @@ import roomescape.payment.web.toCreateResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime import java.time.OffsetDateTime
private val log = KotlinLogging.logger {}
@Service @Service
class PaymentService( class PaymentService(
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository private val canceledPaymentRepository: CanceledPaymentRepository,
) { ) {
@Transactional @Transactional
fun createPayment( fun createPayment(
approveResponse: PaymentApproveResponse, approveResponse: PaymentApproveResponse,
reservation: ReservationEntity reservation: ReservationEntity,
): PaymentCreateResponse { ): PaymentCreateResponse {
log.debug { "[PaymentService.createPayment] 결제 정보 저장 시작: request=$approveResponse, reservationId=${reservation.id}" }
val payment = PaymentEntity( val payment = PaymentEntity(
orderId = approveResponse.orderId, orderId = approveResponse.orderId,
paymentKey = approveResponse.paymentKey, paymentKey = approveResponse.paymentKey,
totalAmount = approveResponse.totalAmount, totalAmount = approveResponse.totalAmount,
reservation = reservation, reservation = reservation,
approvedAt = approveResponse.approvedAt approvedAt = approveResponse.approvedAt
) )
return paymentRepository.save(payment).toCreateResponse() return paymentRepository.save(payment)
.toCreateResponse()
.also { log.info { "[PaymentService.createPayment] 결제 정보 저장 완료: paymentId=${it.id}, reservationId=${reservation.id}" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isReservationPaid(reservationId: Long): Boolean = paymentRepository.existsByReservationId(reservationId) fun isReservationPaid(reservationId: Long): Boolean {
log.debug { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 시작: reservationId=$reservationId" }
return paymentRepository.existsByReservationId(reservationId)
.also { log.info { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 완료: reservationId=$reservationId, isPaid=$it" } }
}
@Transactional @Transactional
fun createCanceledPayment( fun createCanceledPayment(
cancelInfo: PaymentCancelResponse, cancelInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime, approvedAt: OffsetDateTime,
paymentKey: String paymentKey: String,
): CanceledPaymentEntity { ): CanceledPaymentEntity {
log.debug {
"[PaymentService.createCanceledPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" +
", cancelInfo=$cancelInfo"
}
val canceledPayment = CanceledPaymentEntity( val canceledPayment = CanceledPaymentEntity(
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = cancelInfo.cancelReason, cancelReason = cancelInfo.cancelReason,
cancelAmount = cancelInfo.cancelAmount, cancelAmount = cancelInfo.cancelAmount,
approvedAt = approvedAt, approvedAt = approvedAt,
canceledAt = cancelInfo.canceledAt canceledAt = cancelInfo.canceledAt
) )
return canceledPaymentRepository.save(canceledPayment) return canceledPaymentRepository.save(canceledPayment)
.also {
log.info {
"[PaymentService.createCanceledPayment] 결제 취소 정보 생성 완료: canceledPaymentId=${it.id}" +
", paymentKey=${paymentKey}, amount=${cancelInfo.cancelAmount}, canceledAt=${it.canceledAt}"
}
}
} }
@Transactional @Transactional
fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest { fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest {
log.debug { "[PaymentService.createCanceledPaymentByReservationId] 예약 삭제 & 결제 취소 정보 저장 시작: reservationId=$reservationId" }
val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId) val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId)
?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) ?: run {
log.warn { "[PaymentService.createCanceledPaymentByReservationId] 예약 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
// 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다.
val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) val canceled: CanceledPaymentEntity = cancelPayment(paymentKey)
return PaymentCancelRequest(paymentKey, canceled.cancelAmount, canceled.cancelReason) return PaymentCancelRequest(paymentKey, canceled.cancelAmount, canceled.cancelReason)
.also { log.info { "[PaymentService.createCanceledPaymentByReservationId] 예약 ID로 결제 취소 완료: reservationId=$reservationId" } }
} }
private fun cancelPayment( private fun cancelPayment(
paymentKey: String, paymentKey: String,
cancelReason: String = "고객 요청", cancelReason: String = "고객 요청",
canceledAt: OffsetDateTime = OffsetDateTime.now() canceledAt: OffsetDateTime = OffsetDateTime.now(),
): CanceledPaymentEntity { ): CanceledPaymentEntity {
log.debug { "[PaymentService.cancelPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" }
val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey)
?.also { paymentRepository.delete(it) } ?.also {
?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) paymentRepository.delete(it)
log.info { "[PaymentService.cancelPayment] 결제 정보 삭제 완료: paymentId=${it.id}, paymentKey=$paymentKey" }
}
?: run {
log.warn { "[PaymentService.cancelPayment] 결제 정보 조회 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
val canceledPayment = CanceledPaymentEntity( val canceledPayment = CanceledPaymentEntity(
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = cancelReason, cancelReason = cancelReason,
cancelAmount = payment.totalAmount, cancelAmount = payment.totalAmount,
approvedAt = payment.approvedAt, approvedAt = payment.approvedAt,
canceledAt = canceledAt canceledAt = canceledAt
) )
return canceledPaymentRepository.save(canceledPayment) return canceledPaymentRepository.save(canceledPayment)
.also { log.info { "[PaymentService.cancelPayment] 결제 취소 정보 저장 완료: canceledPaymentId=${it.id}" } }
} }
@Transactional @Transactional
fun updateCanceledTime( fun updateCanceledTime(
paymentKey: String, paymentKey: String,
canceledAt: OffsetDateTime canceledAt: OffsetDateTime,
) { ) {
log.debug { "[PaymentService.updateCanceledTime] 취소 시간 업데이트 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" }
canceledPaymentRepository.findByPaymentKey(paymentKey) canceledPaymentRepository.findByPaymentKey(paymentKey)
?.apply { this.canceledAt = canceledAt } ?.apply { this.canceledAt = canceledAt }
?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) ?.also {
log.info {
"[PaymentService.updateCanceledTime] 취소 시간 업데이트 완료: paymentKey=$paymentKey" +
", canceledAt=$canceledAt"
}
}
?: run {
log.warn { "[PaymentService.updateCanceledTime] 결제 정보 조회 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
} }
} }

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class PaymentErrorCode( enum class PaymentErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."), PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."),
CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."), CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."),

View File

@ -3,6 +3,6 @@ package roomescape.payment.exception
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class PaymentException( class PaymentException(
override val errorCode: PaymentErrorCode, override val errorCode: PaymentErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -9,21 +9,21 @@ import roomescape.payment.web.PaymentCancelResponse
import java.time.OffsetDateTime import java.time.OffsetDateTime
class PaymentCancelResponseDeserializer( class PaymentCancelResponseDeserializer(
vc: Class<PaymentCancelResponse>? = null vc: Class<PaymentCancelResponse>? = null
) : StdDeserializer<PaymentCancelResponse>(vc) { ) : StdDeserializer<PaymentCancelResponse>(vc) {
override fun deserialize( override fun deserialize(
jsonParser: JsonParser, jsonParser: JsonParser,
deserializationContext: DeserializationContext? deserializationContext: DeserializationContext?
): PaymentCancelResponse { ): PaymentCancelResponse {
val cancels: JsonNode = jsonParser.codec.readTree<TreeNode>(jsonParser) val cancels: JsonNode = jsonParser.codec.readTree<TreeNode>(jsonParser)
.get("cancels") .get("cancels")
.get(0) as JsonNode .get(0) as JsonNode
return PaymentCancelResponse( return PaymentCancelResponse(
cancels.get("cancelStatus").asText(), cancels.get("cancelStatus").asText(),
cancels.get("cancelReason").asText(), cancels.get("cancelReason").asText(),
cancels.get("cancelAmount").asLong(), cancels.get("cancelAmount").asLong(),
OffsetDateTime.parse(cancels.get("canceledAt").asText()) OffsetDateTime.parse(cancels.get("canceledAt").asText())
) )
} }
} }

View File

@ -16,7 +16,7 @@ class PaymentConfig {
@Bean @Bean
fun tossPaymentClientBuilder( fun tossPaymentClientBuilder(
paymentProperties: PaymentProperties, paymentProperties: PaymentProperties,
): RestClient.Builder { ): RestClient.Builder {
val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also { val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also {
it.withReadTimeout(Duration.ofSeconds(paymentProperties.readTimeout.toLong())) it.withReadTimeout(Duration.ofSeconds(paymentProperties.readTimeout.toLong()))
@ -25,14 +25,14 @@ class PaymentConfig {
val requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings) val requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings)
return RestClient.builder() return RestClient.builder()
.baseUrl(paymentProperties.apiBaseUrl) .baseUrl(paymentProperties.apiBaseUrl)
.defaultHeader("Authorization", getAuthorizations(paymentProperties.confirmSecretKey)) .defaultHeader("Authorization", getAuthorizations(paymentProperties.confirmSecretKey))
.requestFactory(requestFactory) .requestFactory(requestFactory)
} }
private fun getAuthorizations(secretKey: String): String { private fun getAuthorizations(secretKey: String): String {
val encodedSecretKey = Base64.getEncoder() val encodedSecretKey = Base64.getEncoder()
.encodeToString("$secretKey:".toByteArray(StandardCharsets.UTF_8)) .encodeToString("$secretKey:".toByteArray(StandardCharsets.UTF_8))
return "Basic $encodedSecretKey" return "Basic $encodedSecretKey"
} }

View File

@ -4,8 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "payment") @ConfigurationProperties(prefix = "payment")
data class PaymentProperties( data class PaymentProperties(
val apiBaseUrl: String, val apiBaseUrl: String,
val confirmSecretKey: String, val confirmSecretKey: String,
val readTimeout: Int, val readTimeout: Int,
val connectTimeout: Int val connectTimeout: Int
) )

View File

@ -15,11 +15,12 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import java.util.Map import java.util.Map
private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class TossPaymentClient( class TossPaymentClient(
private val log: KLogger = KotlinLogging.logger {}, private val objectMapper: ObjectMapper,
private val objectMapper: ObjectMapper, tossPaymentClientBuilder: RestClient.Builder,
tossPaymentClientBuilder: RestClient.Builder,
) { ) {
companion object { companion object {
private const val CONFIRM_URL: String = "/v1/payments/confirm" private const val CONFIRM_URL: String = "/v1/payments/confirm"
@ -32,16 +33,19 @@ class TossPaymentClient(
logPaymentInfo(paymentRequest) logPaymentInfo(paymentRequest)
return tossPaymentClient.post() return tossPaymentClient.post()
.uri(CONFIRM_URL) .uri(CONFIRM_URL)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(paymentRequest) .body(paymentRequest)
.retrieve() .retrieve()
.onStatus( .onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError }, { status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "confirm") }
) )
.body(PaymentApproveResponse::class.java) .body(PaymentApproveResponse::class.java)
?: throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) ?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
} }
fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse { fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse {
@ -49,47 +53,49 @@ class TossPaymentClient(
val param = Map.of<String, String>("cancelReason", cancelRequest.cancelReason) val param = Map.of<String, String>("cancelReason", cancelRequest.cancelReason)
return tossPaymentClient.post() return tossPaymentClient.post()
.uri(CANCEL_URL, cancelRequest.paymentKey) .uri(CANCEL_URL, cancelRequest.paymentKey)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(param) .body(param)
.retrieve() .retrieve()
.onStatus( .onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError }, { status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "cancel") }
) )
.body(PaymentCancelResponse::class.java) .body(PaymentCancelResponse::class.java)
?: throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) ?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
} }
private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) { private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) {
log.info { log.info {
"결제 승인 요청: paymentKey=${paymentRequest.paymentKey}, orderId=${paymentRequest.orderId}, " + "[TossPaymentClient.confirm] 결제 승인 요청: request: $paymentRequest"
"amount=${paymentRequest.amount}, paymentType=${paymentRequest.paymentType}"
} }
} }
private fun logPaymentCancelInfo(cancelRequest: PaymentCancelRequest) { private fun logPaymentCancelInfo(cancelRequest: PaymentCancelRequest) {
log.info { log.info {
"결제 취소 요청: paymentKey=${cancelRequest.paymentKey}, amount=${cancelRequest.amount}, " + "[TossPaymentClient.cancel] 결제 취소 요청: request: $cancelRequest"
"cancelReason=${cancelRequest.cancelReason}"
} }
} }
private fun handlePaymentError( private fun handlePaymentError(
res: ClientHttpResponse res: ClientHttpResponse,
calledBy: String
): Nothing { ): Nothing {
getErrorCodeByHttpStatus(res.statusCode).also { getErrorCodeByHttpStatus(res.statusCode).also {
logTossPaymentError(res) logTossPaymentError(res, calledBy)
throw PaymentException(it) throw PaymentException(it)
} }
} }
private fun logTossPaymentError(res: ClientHttpResponse): TossPaymentErrorResponse { private fun logTossPaymentError(res: ClientHttpResponse, calledBy: String): TossPaymentErrorResponse {
val body = res.body val body = res.body
val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java) val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java)
body.close() body.close()
log.error { "결제 실패. response: $errorResponse" } log.error { "[TossPaymentClient.$calledBy] 요청 실패: response: $errorResponse" }
return errorResponse return errorResponse
} }

View File

@ -4,21 +4,21 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class TossPaymentErrorResponse( data class TossPaymentErrorResponse(
val code: String, val code: String,
val message: String val message: String
) )
data class PaymentApproveRequest( data class PaymentApproveRequest(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,
val amount: Long, val amount: Long,
val paymentType: String val paymentType: String
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class PaymentApproveResponse( data class PaymentApproveResponse(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,
val totalAmount: Long, val totalAmount: Long,
val approvedAt: OffsetDateTime val approvedAt: OffsetDateTime
) )

View File

@ -6,13 +6,13 @@ import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "canceled_payments") @Table(name = "canceled_payments")
class CanceledPaymentEntity( class CanceledPaymentEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
var paymentKey: String, var paymentKey: String,
var cancelReason: String, var cancelReason: String,
var cancelAmount: Long, var cancelAmount: Long,
var approvedAt: OffsetDateTime, var approvedAt: OffsetDateTime,
var canceledAt: OffsetDateTime, var canceledAt: OffsetDateTime,
) )

View File

@ -7,23 +7,23 @@ import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "payments") @Table(name = "payments")
class PaymentEntity( class PaymentEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
@Column(nullable = false) @Column(nullable = false)
var orderId: String, var orderId: String,
@Column(nullable = false) @Column(nullable = false)
var paymentKey: String, var paymentKey: String,
@Column(nullable = false) @Column(nullable = false)
var totalAmount: Long, var totalAmount: Long,
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false) @JoinColumn(name = "reservation_id", nullable = false)
var reservation: ReservationEntity, var reservation: ReservationEntity,
@Column(nullable = false) @Column(nullable = false)
var approvedAt: OffsetDateTime var approvedAt: OffsetDateTime
) )

View File

@ -8,33 +8,33 @@ import roomescape.reservation.web.toRetrieveResponse
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentCancelRequest( data class PaymentCancelRequest(
val paymentKey: String, val paymentKey: String,
val amount: Long, val amount: Long,
val cancelReason: String val cancelReason: String
) )
@JsonDeserialize(using = PaymentCancelResponseDeserializer::class) @JsonDeserialize(using = PaymentCancelResponseDeserializer::class)
data class PaymentCancelResponse( data class PaymentCancelResponse(
val cancelStatus: String, val cancelStatus: String,
val cancelReason: String, val cancelReason: String,
val cancelAmount: Long, val cancelAmount: Long,
val canceledAt: OffsetDateTime val canceledAt: OffsetDateTime
) )
data class PaymentCreateResponse( data class PaymentCreateResponse(
val id: Long, val id: Long,
val orderId: String, val orderId: String,
val paymentKey: String, val paymentKey: String,
val totalAmount: Long, val totalAmount: Long,
val reservation: ReservationRetrieveResponse, val reservation: ReservationRetrieveResponse,
val approvedAt: OffsetDateTime val approvedAt: OffsetDateTime
) )
fun PaymentEntity.toCreateResponse(): PaymentCreateResponse = PaymentCreateResponse( fun PaymentEntity.toCreateResponse(): PaymentCreateResponse = PaymentCreateResponse(
id = this.id!!, id = this.id!!,
orderId = this.orderId, orderId = this.orderId,
paymentKey = this.paymentKey, paymentKey = this.paymentKey,
totalAmount = this.totalAmount, totalAmount = this.totalAmount,
reservation = this.reservation.toRetrieveResponse(), reservation = this.reservation.toRetrieveResponse(),
approvedAt = this.approvedAt approvedAt = this.approvedAt
) )

View File

@ -1,5 +1,6 @@
package roomescape.reservation.business package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -20,31 +21,37 @@ import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
private val log = KotlinLogging.logger {}
@Service @Service
@Transactional @Transactional
class ReservationService( class ReservationService(
private val reservationRepository: ReservationRepository, private val reservationRepository: ReservationRepository,
private val timeService: TimeService, private val timeService: TimeService,
private val memberService: MemberService, private val memberService: MemberService,
private val themeService: ThemeService, private val themeService: ThemeService,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findReservations(): ReservationRetrieveListResponse { fun findReservations(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed() .confirmed()
.build() .build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findReservations] ${reservations.size} 개의 확정 예약 조회 완료" }
return ReservationRetrieveListResponse(findAllReservationByStatus(spec)) return ReservationRetrieveListResponse(reservations)
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAllWaiting(): ReservationRetrieveListResponse { fun findAllWaiting(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.waiting() .waiting()
.build() .build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findAllWaiting] ${reservations.size} 개의 대기 예약 조회 완료" }
return ReservationRetrieveListResponse(findAllReservationByStatus(spec)) return ReservationRetrieveListResponse(reservations)
} }
private fun findAllReservationByStatus(spec: Specification<ReservationEntity>): List<ReservationRetrieveResponse> { private fun findAllReservationByStatus(spec: Specification<ReservationEntity>): List<ReservationRetrieveResponse> {
@ -52,102 +59,127 @@ class ReservationService(
} }
fun deleteReservation(reservationId: Long, memberId: Long) { fun deleteReservation(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId) validateIsMemberAdmin(memberId, "deleteReservation")
log.info { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" }
reservationRepository.deleteById(reservationId) reservationRepository.deleteById(reservationId)
log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" }
} }
fun createConfirmedReservation( fun createConfirmedReservation(
request: ReservationCreateWithPaymentRequest, request: ReservationCreateWithPaymentRequest,
memberId: Long memberId: Long,
): ReservationEntity { ): ReservationEntity {
val themeId = request.themeId val themeId = request.themeId
val timeId = request.timeId val timeId = request.timeId
val date: LocalDate = request.date val date: LocalDate = request.date
validateIsReservationExist(themeId, timeId, date) validateIsReservationExist(themeId, timeId, date, "createConfirmedReservation")
val reservation: ReservationEntity = createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED) log.debug { "[ReservationService.createConfirmedReservation] 예약 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
val reservation: ReservationEntity =
createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED)
return reservationRepository.save(reservation) return reservationRepository.save(reservation)
.also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.reservationStatus}" } }
} }
fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse {
validateIsReservationExist(request.themeId, request.timeId, request.date) validateIsReservationExist(request.themeId, request.timeId, request.date)
log.debug { "[ReservationService.createReservationByAdmin] 관리자의 예약 추가: memberId=${request.memberId}, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment( return addReservationWithoutPayment(
request.themeId, request.themeId,
request.timeId, request.timeId,
request.date, request.date,
request.memberId, request.memberId,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
) ).also {
log.info { "[ReservationService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
} }
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse { fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse {
validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId) validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId)
log.debug { "[ReservationService.createWaiting] 예약 대기 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment( return addReservationWithoutPayment(
request.themeId, request.themeId,
request.timeId, request.timeId,
request.date, request.date,
memberId, memberId,
ReservationStatus.WAITING ReservationStatus.WAITING
) ).also {
log.info { "[ReservationService.createWaiting] 예약 대기 추가 완료: reservationId=${it.id}, status=${it.status}" }
}
} }
private fun addReservationWithoutPayment( private fun addReservationWithoutPayment(
themeId: Long, themeId: Long,
timeId: Long, timeId: Long,
date: LocalDate, date: LocalDate,
memberId: Long, memberId: Long,
status: ReservationStatus status: ReservationStatus,
): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status) ): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status)
.also { .also {
reservationRepository.save(it) reservationRepository.save(it)
}.toRetrieveResponse() }.toRetrieveResponse()
private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) { private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) {
log.debug {
"[ReservationService.validateMemberAlreadyReserve] 회원의 중복 예약 여부 확인: themeId=$themeId, timeId=$timeId, date=$date, memberId=$memberId"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(memberId) .sameMemberId(memberId)
.sameThemeId(themeId) .sameThemeId(themeId)
.sameTimeId(timeId) .sameTimeId(timeId)
.sameDate(date) .sameDate(date)
.build() .build()
if (reservationRepository.exists(spec)) { if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.ALREADY_RESERVE) throw ReservationException(ReservationErrorCode.ALREADY_RESERVE)
} }
} }
private fun validateIsReservationExist(themeId: Long, timeId: Long, date: LocalDate) { private fun validateIsReservationExist(
themeId: Long,
timeId: Long,
date: LocalDate,
calledBy: String = "validateIsReservationExist"
) {
log.debug {
"[ReservationService.$calledBy] 예약 존재 여부 확인: themeId=$themeId, timeId=$timeId, date=$date"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed() .confirmed()
.sameThemeId(themeId) .sameThemeId(themeId)
.sameTimeId(timeId) .sameTimeId(timeId)
.sameDate(date) .sameDate(date)
.build() .build()
if (reservationRepository.exists(spec)) { if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.$calledBy] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
} }
} }
private fun validateDateAndTime( private fun validateDateAndTime(
requestDate: LocalDate, requestDate: LocalDate,
requestTime: TimeEntity requestTime: TimeEntity,
) { ) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val request = LocalDateTime.of(requestDate, requestTime.startAt) val request = LocalDateTime.of(requestDate, requestTime.startAt)
if (request.isBefore(now)) { if (request.isBefore(now)) {
log.info { "[ReservationService.validateDateAndTime] 날짜 범위 오류. request=$request, now=$now" }
throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
} }
} }
private fun createEntity( private fun createEntity(
timeId: Long, timeId: Long,
themeId: Long, themeId: Long,
date: LocalDate, date: LocalDate,
memberId: Long, memberId: Long,
status: ReservationStatus status: ReservationStatus,
): ReservationEntity { ): ReservationEntity {
val time: TimeEntity = timeService.findById(timeId) val time: TimeEntity = timeService.findById(timeId)
val theme: ThemeEntity = themeService.findById(themeId) val theme: ThemeEntity = themeService.findById(themeId)
@ -156,86 +188,132 @@ class ReservationService(
validateDateAndTime(date, time) validateDateAndTime(date, time)
return ReservationEntity( return ReservationEntity(
date = date, date = date,
time = time, time = time,
theme = theme, theme = theme,
member = member, member = member,
reservationStatus = status reservationStatus = status
) )
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchReservations( fun searchReservations(
themeId: Long?, themeId: Long?,
memberId: Long?, memberId: Long?,
dateFrom: LocalDate?, dateFrom: LocalDate?,
dateTo: LocalDate? dateTo: LocalDate?,
): ReservationRetrieveListResponse { ): ReservationRetrieveListResponse {
validateDateForSearch(dateFrom, dateTo) log.debug { "[ReservationService.searchReservations] 예약 검색 시작: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" }
validateSearchDateRange(dateFrom, dateTo)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed() .confirmed()
.sameThemeId(themeId) .sameThemeId(themeId)
.sameMemberId(memberId) .sameMemberId(memberId)
.dateStartFrom(dateFrom) .dateStartFrom(dateFrom)
.dateEndAt(dateTo) .dateEndAt(dateTo)
.build() .build()
val reservations = findAllReservationByStatus(spec)
return ReservationRetrieveListResponse(findAllReservationByStatus(spec)) return ReservationRetrieveListResponse(reservations)
.also { log.info { "[ReservationService.searchReservations] 예약 ${reservations.size}개 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } }
} }
private fun validateDateForSearch(startFrom: LocalDate?, endAt: LocalDate?) { private fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) {
if (startFrom == null || endAt == null) { if (startFrom == null || endAt == null) {
return return
} }
if (startFrom.isAfter(endAt)) { if (startFrom.isAfter(endAt)) {
log.info { "[ReservationService.validateSearchDateRange] 조회 범위 오류: startFrom=$startFrom, endAt=$endAt" }
throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
return MyReservationRetrieveListResponse(reservationRepository.findAllByMemberId(memberId)) val reservations = reservationRepository.findAllByMemberId(memberId)
log.info { "[ReservationService.findReservationsByMemberId] memberId=${memberId}${reservations.size}개의 예약 조회 완료" }
return MyReservationRetrieveListResponse(reservations)
} }
fun confirmWaiting(reservationId: Long, memberId: Long) { fun confirmWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId) log.debug { "[ReservationService.confirmWaiting] 대기 예약 승인 시작: reservationId=$reservationId (by adminId=$memberId)" }
validateIsMemberAdmin(memberId, "confirmWaiting")
log.debug { "[ReservationService.confirmWaiting] 대기 여부 확인 시작: reservationId=$reservationId" }
if (reservationRepository.isExistConfirmedReservation(reservationId)) { if (reservationRepository.isExistConfirmedReservation(reservationId)) {
log.warn { "[ReservationService.confirmWaiting] 승인 실패(이미 확정된 예약 존재): reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
} }
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 시작: reservationId=$reservationId" }
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
log.info { "[ReservationService.confirmWaiting] 대기 예약 승인 완료: reservationId=$reservationId" }
} }
fun deleteWaiting(reservationId: Long, memberId: Long) { fun deleteWaiting(reservationId: Long, memberId: Long) {
val reservation: ReservationEntity = findReservationOrThrow(reservationId) log.debug { "[ReservationService.deleteWaiting] 대기 취소 시작: reservationId=$reservationId, memberId=$memberId" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "deleteWaiting")
if (!reservation.isWaiting()) { if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" +
", currentStatus=${reservation.reservationStatus} memberId=$memberId"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
} }
if (!reservation.isReservedBy(memberId)) { if (!reservation.isReservedBy(memberId)) {
log.error {
"[ReservationService.deleteWaiting] 대기 취소 실패(예약자 본인의 취소 요청이 아님): reservationId=$reservationId" +
", memberId=$memberId "
}
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
} }
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" }
reservationRepository.delete(reservation) reservationRepository.delete(reservation)
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" }
log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" }
} }
fun rejectWaiting(reservationId: Long, memberId: Long) { fun rejectWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId) validateIsMemberAdmin(memberId, "rejectWaiting")
val reservation: ReservationEntity = findReservationOrThrow(reservationId) log.debug { "[ReservationService.rejectWaiting] 대기 예약 삭제 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "rejectWaiting")
if (!reservation.isWaiting()) { if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" +
", status=${reservation.reservationStatus}"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
} }
reservationRepository.delete(reservation) reservationRepository.delete(reservation)
log.info { "[ReservationService.rejectWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" }
} }
private fun validateIsMemberAdmin(memberId: Long) { private fun validateIsMemberAdmin(memberId: Long, calledBy: String = "validateIsMemberAdmin") {
log.debug { "[ReservationService.$calledBy] 관리자 여부 확인: memberId=$memberId" }
val member: MemberEntity = memberService.findById(memberId) val member: MemberEntity = memberService.findById(memberId)
if (member.isAdmin()) { if (member.isAdmin()) {
return return
} }
log.warn { "[ReservationService.$calledBy] 관리자가 아님: memberId=$memberId, role=${member.role}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION) throw ReservationException(ReservationErrorCode.NO_PERMISSION)
} }
private fun findReservationOrThrow(reservationId: Long): ReservationEntity { private fun findReservationOrThrow(
reservationId: Long,
calledBy: String = "findReservationOrThrow"
): ReservationEntity {
log.debug { "[ReservationService.$calledBy] 예약 조회: reservationId=$reservationId" }
return reservationRepository.findByIdOrNull(reservationId) return reservationRepository.findByIdOrNull(reservationId)
?: throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) ?.also { log.info { "[ReservationService.$calledBy] 예약 조회 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[ReservationService.$calledBy] 예약 조회 실패: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
} }
} }

View File

@ -1,5 +1,6 @@
package roomescape.reservation.business package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.payment.business.PaymentService import roomescape.payment.business.PaymentService
@ -11,47 +12,57 @@ import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.ReservationRetrieveResponse import roomescape.reservation.web.ReservationRetrieveResponse
import java.time.OffsetDateTime import java.time.OffsetDateTime
private val log = KotlinLogging.logger {}
@Service @Service
@Transactional @Transactional
class ReservationWithPaymentService( class ReservationWithPaymentService(
private val reservationService: ReservationService, private val reservationService: ReservationService,
private val paymentService: PaymentService private val paymentService: PaymentService,
) { ) {
fun createReservationAndPayment( fun createReservationAndPayment(
request: ReservationCreateWithPaymentRequest, request: ReservationCreateWithPaymentRequest,
paymentInfo: PaymentApproveResponse, paymentInfo: PaymentApproveResponse,
memberId: Long memberId: Long,
): ReservationRetrieveResponse { ): ReservationRetrieveResponse {
log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 시작: memberId=$memberId, paymentInfo=$paymentInfo" }
val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId) val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId)
return paymentService.createPayment(paymentInfo, reservation) return paymentService.createPayment(paymentInfo, reservation)
.reservation .also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 완료: reservationId=${reservation.id}, paymentId=${it.id}" } }
.reservation
} }
fun createCanceledPayment( fun createCanceledPayment(
cancelInfo: PaymentCancelResponse, cancelInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime, approvedAt: OffsetDateTime,
paymentKey: String paymentKey: String,
) { ) {
paymentService.createCanceledPayment(cancelInfo, approvedAt, paymentKey) paymentService.createCanceledPayment(cancelInfo, approvedAt, paymentKey)
} }
fun deleteReservationAndPayment( fun deleteReservationAndPayment(
reservationId: Long, reservationId: Long,
memberId: Long memberId: Long,
): PaymentCancelRequest { ): PaymentCancelRequest {
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 시작: reservationId=$reservationId" }
val paymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId) val paymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId)
reservationService.deleteReservation(reservationId, memberId)
reservationService.deleteReservation(reservationId, memberId)
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 완료: reservationId=$reservationId" }
return paymentCancelRequest return paymentCancelRequest
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isNotPaidReservation(reservationId: Long): Boolean = !paymentService.isReservationPaid(reservationId) fun isNotPaidReservation(reservationId: Long): Boolean {
log.debug { "[ReservationWithPaymentService.isNotPaidReservation] 예약 결제 여부 확인: reservationId=$reservationId" }
return !paymentService.isReservationPaid(reservationId)
.also { log.info { "[ReservationWithPaymentService.isNotPaidReservation] 결제 여부 확인 완료: reservationId=$reservationId, 결제 여부=${!it}" } }
}
fun updateCanceledTime( fun updateCanceledTime(
paymentKey: String, paymentKey: String,
canceledAt: OffsetDateTime canceledAt: OffsetDateTime,
) { ) {
paymentService.updateCanceledTime(paymentKey, canceledAt) paymentService.updateCanceledTime(paymentKey, canceledAt)
} }

View File

@ -32,58 +32,66 @@ interface ReservationAPI {
@Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"]) @Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findReservationsByMemberId( fun findReservationsByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> ): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>>
@Admin @Admin
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true) ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
) )
fun searchReservations( fun searchReservations(
@RequestParam(required = false) themeId: Long?, @RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) memberId: Long?, @RequestParam(required = false) memberId: Long?,
@RequestParam(required = false) dateFrom: LocalDate?, @RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate? @RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> ): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>>
@Admin @Admin
@Operation(summary = "관리자의 예약 취소", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "관리자의 예약 취소", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "204", description = "성공"), ApiResponse(responseCode = "204", description = "성공"),
) )
fun cancelReservationByAdmin( fun cancelReservationByAdmin(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "201", responseCode = "201",
description = "성공", description = "성공",
useReturnTypeSchema = true, useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))] headers = [Header(
) name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
) )
fun createReservationWithPayment( fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>>
@Admin @Admin
@Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "201", responseCode = "201",
description = "성공", description = "성공",
useReturnTypeSchema = true, useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))], headers = [Header(
) name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)],
)
) )
fun createReservationByAdmin( fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest, @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>>
@Admin @Admin
@ -94,45 +102,49 @@ interface ReservationAPI {
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "201", responseCode = "201",
description = "성공", description = "성공",
useReturnTypeSchema = true, useReturnTypeSchema = true,
headers = [Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = Schema(example = "/reservations/1"))] headers = [Header(
) name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
) )
fun createWaiting( fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "204", description = "성공"), ApiResponse(responseCode = "204", description = "성공"),
) )
fun cancelWaitingByMember( fun cancelWaitingByMember(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long @PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@Admin @Admin
@Operation(summary = "대기 중인 예약 승인", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "대기 중인 예약 승인", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "성공"), ApiResponse(responseCode = "200", description = "성공"),
) )
fun confirmWaiting( fun confirmWaiting(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long @PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@Admin @Admin
@Operation(summary = "대기 중인 예약 거절", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "대기 중인 예약 거절", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"), ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
) )
fun rejectWaiting( fun rejectWaiting(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long @PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class ReservationErrorCode( enum class ReservationErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."), RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."), RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."),

View File

@ -4,6 +4,6 @@ import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class ReservationException( class ReservationException(
override val errorCode: ErrorCode, override val errorCode: ErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -10,26 +10,26 @@ import java.time.LocalDate
@Entity @Entity
@Table(name = "reservations") @Table(name = "reservations")
class ReservationEntity( class ReservationEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
var date: LocalDate, var date: LocalDate,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_id", nullable = false) @JoinColumn(name = "time_id", nullable = false)
var time: TimeEntity, var time: TimeEntity,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "theme_id", nullable = false) @JoinColumn(name = "theme_id", nullable = false)
var theme: ThemeEntity, var theme: ThemeEntity,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
var member: MemberEntity, var member: MemberEntity,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var reservationStatus: ReservationStatus var reservationStatus: ReservationStatus
) { ) {
@JsonIgnore @JsonIgnore
fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING

View File

@ -16,17 +16,20 @@ interface ReservationRepository
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity> fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
@Modifying @Modifying
@Query(""" @Query(
"""
UPDATE ReservationEntity r UPDATE ReservationEntity r
SET r.reservationStatus = :status SET r.reservationStatus = :status
WHERE r.id = :id WHERE r.id = :id
""") """
)
fun updateStatusByReservationId( fun updateStatusByReservationId(
@Param(value = "id") reservationId: Long, @Param(value = "id") reservationId: Long,
@Param(value = "status") statusForChange: ReservationStatus @Param(value = "status") statusForChange: ReservationStatus
): Int ): Int
@Query(""" @Query(
"""
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 SELECT 1
FROM ReservationEntity r2 FROM ReservationEntity r2
@ -39,10 +42,12 @@ interface ReservationRepository
AND r.reservationStatus != 'WAITING' AND r.reservationStatus != 'WAITING'
) )
) )
""") """
)
fun isExistConfirmedReservation(@Param("id") reservationId: Long): Boolean fun isExistConfirmedReservation(@Param("id") reservationId: Long): Boolean
@Query(""" @Query(
"""
SELECT new roomescape.reservation.web.MyReservationRetrieveResponse( SELECT new roomescape.reservation.web.MyReservationRetrieveResponse(
r.id, r.id,
t.name, t.name,
@ -58,6 +63,7 @@ interface ReservationRepository
LEFT JOIN PaymentEntity p LEFT JOIN PaymentEntity p
ON p.reservation = r ON p.reservation = r
WHERE r.member.id = :memberId WHERE r.member.id = :memberId
""") """
)
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
} }

View File

@ -7,7 +7,7 @@ import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
class ReservationSearchSpecification( class ReservationSearchSpecification(
private var spec: Specification<ReservationEntity> = Specification { _, _, _ -> null } private var spec: Specification<ReservationEntity> = Specification { _, _, _ -> null }
) { ) {
fun sameThemeId(themeId: Long?): ReservationSearchSpecification = andIfNotNull(themeId?.let { fun sameThemeId(themeId: Long?): ReservationSearchSpecification = andIfNotNull(themeId?.let {
Specification { root, _, cb -> Specification { root, _, cb ->
@ -35,21 +35,21 @@ class ReservationSearchSpecification(
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or( cb.or(
cb.equal( cb.equal(
root.get<ReservationStatus>("reservationStatus"), root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.CONFIRMED ReservationStatus.CONFIRMED
), ),
cb.equal( cb.equal(
root.get<ReservationStatus>("reservationStatus"), root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
) )
) )
} }
fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.equal( cb.equal(
root.get<ReservationStatus>("reservationStatus"), root.get<ReservationStatus>("reservationStatus"),
ReservationStatus.WAITING ReservationStatus.WAITING
) )
} }

View File

@ -18,9 +18,9 @@ import java.time.LocalDate
@RestController @RestController
class ReservationController( class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService, private val reservationWithPaymentService: ReservationWithPaymentService,
private val reservationService: ReservationService, private val reservationService: ReservationService,
private val paymentClient: TossPaymentClient private val paymentClient: TossPaymentClient
) : ReservationAPI { ) : ReservationAPI {
@GetMapping("/reservations") @GetMapping("/reservations")
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
@ -31,7 +31,7 @@ class ReservationController(
@GetMapping("/reservations-mine") @GetMapping("/reservations-mine")
override fun findReservationsByMemberId( override fun findReservationsByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> {
val response: MyReservationRetrieveListResponse = reservationService.findReservationsByMemberId(memberId) val response: MyReservationRetrieveListResponse = reservationService.findReservationsByMemberId(memberId)
@ -40,16 +40,16 @@ class ReservationController(
@GetMapping("/reservations/search") @GetMapping("/reservations/search")
override fun searchReservations( override fun searchReservations(
@RequestParam(required = false) themeId: Long?, @RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) memberId: Long?, @RequestParam(required = false) memberId: Long?,
@RequestParam(required = false) dateFrom: LocalDate?, @RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate? @RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.searchReservations( val response: ReservationRetrieveListResponse = reservationService.searchReservations(
themeId, themeId,
memberId, memberId,
dateFrom, dateFrom,
dateTo dateTo
) )
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
@ -57,8 +57,8 @@ class ReservationController(
@DeleteMapping("/reservations/{id}") @DeleteMapping("/reservations/{id}")
override fun cancelReservationByAdmin( override fun cancelReservationByAdmin(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) { if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationService.deleteReservation(reservationId, memberId) reservationService.deleteReservation(reservationId, memberId)
@ -67,47 +67,56 @@ class ReservationController(
val paymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(reservationId, memberId) val paymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(reservationId, memberId)
val paymentCancelResponse = paymentClient.cancel(paymentCancelRequest) val paymentCancelResponse = paymentClient.cancel(paymentCancelRequest)
reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey, reservationWithPaymentService.updateCanceledTime(
paymentCancelResponse.canceledAt) paymentCancelRequest.paymentKey,
paymentCancelResponse.canceledAt
)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@PostMapping("/reservations") @PostMapping("/reservations")
override fun createReservationWithPayment( override fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> {
val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest() val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest()
val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest) val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest)
try { try {
val reservationRetrieveResponse: ReservationRetrieveResponse = reservationWithPaymentService.createReservationAndPayment( val reservationRetrieveResponse: ReservationRetrieveResponse =
reservationWithPaymentService.createReservationAndPayment(
reservationCreateWithPaymentRequest, reservationCreateWithPaymentRequest,
paymentResponse, paymentResponse,
memberId memberId
) )
return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}")) return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}"))
.body(CommonApiResponse(reservationRetrieveResponse)) .body(CommonApiResponse(reservationRetrieveResponse))
} catch (e: Exception) { } catch (e: Exception) {
val cancelRequest = PaymentCancelRequest(paymentRequest.paymentKey, val cancelRequest = PaymentCancelRequest(
paymentRequest.amount, e.message!!) paymentRequest.paymentKey,
paymentRequest.amount,
e.message!!
)
val paymentCancelResponse = paymentClient.cancel(cancelRequest) val paymentCancelResponse = paymentClient.cancel(cancelRequest)
reservationWithPaymentService.createCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt, reservationWithPaymentService.createCanceledPayment(
paymentRequest.paymentKey) paymentCancelResponse,
paymentResponse.approvedAt,
paymentRequest.paymentKey
)
throw e throw e
} }
} }
@PostMapping("/reservations/admin") @PostMapping("/reservations/admin")
override fun createReservationByAdmin( override fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> {
val response: ReservationRetrieveResponse = val response: ReservationRetrieveResponse =
reservationService.createReservationByAdmin(adminReservationRequest) reservationService.createReservationByAdmin(adminReservationRequest)
return ResponseEntity.created(URI.create("/reservations/${response.id}")) return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
} }
@GetMapping("/reservations/waiting") @GetMapping("/reservations/waiting")
@ -119,22 +128,22 @@ class ReservationController(
@PostMapping("/reservations/waiting") @PostMapping("/reservations/waiting")
override fun createWaiting( override fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> {
val response: ReservationRetrieveResponse = reservationService.createWaiting( val response: ReservationRetrieveResponse = reservationService.createWaiting(
waitingCreateRequest, waitingCreateRequest,
memberId memberId
) )
return ResponseEntity.created(URI.create("/reservations/${response.id}")) return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
} }
@DeleteMapping("/reservations/waiting/{id}") @DeleteMapping("/reservations/waiting/{id}")
override fun cancelWaitingByMember( override fun cancelWaitingByMember(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.deleteWaiting(reservationId, memberId) reservationService.deleteWaiting(reservationId, memberId)
@ -143,8 +152,8 @@ class ReservationController(
@PostMapping("/reservations/waiting/{id}/confirm") @PostMapping("/reservations/waiting/{id}/confirm")
override fun confirmWaiting( override fun confirmWaiting(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.confirmWaiting(reservationId, memberId) reservationService.confirmWaiting(reservationId, memberId)
@ -153,8 +162,8 @@ class ReservationController(
@PostMapping("/reservations/waiting/{id}/reject") @PostMapping("/reservations/waiting/{id}/reject")
override fun rejectWaiting( override fun rejectWaiting(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.rejectWaiting(reservationId, memberId) reservationService.rejectWaiting(reservationId, memberId)

View File

@ -5,36 +5,36 @@ import roomescape.payment.infrastructure.client.PaymentApproveRequest
import java.time.LocalDate import java.time.LocalDate
data class AdminReservationCreateRequest( data class AdminReservationCreateRequest(
val date: LocalDate, val date: LocalDate,
val timeId: Long, val timeId: Long,
val themeId: Long, val themeId: Long,
val memberId: Long val memberId: Long
) )
data class ReservationCreateWithPaymentRequest( data class ReservationCreateWithPaymentRequest(
val date: LocalDate, val date: LocalDate,
val timeId: Long, val timeId: Long,
val themeId: Long, val themeId: Long,
@Schema(description = "결제 위젯을 통해 받은 결제 키") @Schema(description = "결제 위젯을 통해 받은 결제 키")
val paymentKey: String, val paymentKey: String,
@Schema(description = "결제 위젯을 통해 받은 주문번호.") @Schema(description = "결제 위젯을 통해 받은 주문번호.")
val orderId: String, val orderId: String,
@Schema(description = "결제 위젯을 통해 받은 결제 금액") @Schema(description = "결제 위젯을 통해 받은 결제 금액")
val amount: Long, val amount: Long,
@Schema(description = "결제 타입", example = "NORMAL") @Schema(description = "결제 타입", example = "NORMAL")
val paymentType: String val paymentType: String
) )
fun ReservationCreateWithPaymentRequest.toPaymentApproveRequest(): PaymentApproveRequest = PaymentApproveRequest( fun ReservationCreateWithPaymentRequest.toPaymentApproveRequest(): PaymentApproveRequest = PaymentApproveRequest(
paymentKey, orderId, amount, paymentType paymentKey, orderId, amount, paymentType
) )
data class WaitingCreateRequest( data class WaitingCreateRequest(
val date: LocalDate, val date: LocalDate,
val timeId: Long, val timeId: Long,
val themeId: Long val themeId: Long
) )

View File

@ -14,49 +14,49 @@ import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
data class MyReservationRetrieveResponse( data class MyReservationRetrieveResponse(
val id: Long, val id: Long,
val themeName: String, val themeName: String,
val date: LocalDate, val date: LocalDate,
val time: LocalTime, val time: LocalTime,
val status: ReservationStatus, val status: ReservationStatus,
@Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.") @Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.")
val rank: Long, val rank: Long,
@Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.") @Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
val paymentKey: String?, val paymentKey: String?,
@Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.") @Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
val amount: Long? val amount: Long?
) )
data class MyReservationRetrieveListResponse( data class MyReservationRetrieveListResponse(
@Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") @Schema(description = "현재 로그인한 회원의 예약 및 대기 목록")
val reservations: List<MyReservationRetrieveResponse> val reservations: List<MyReservationRetrieveResponse>
) )
data class ReservationRetrieveResponse( data class ReservationRetrieveResponse(
val id: Long, val id: Long,
val date: LocalDate, val date: LocalDate,
@field:JsonProperty("member") @field:JsonProperty("member")
val member: MemberRetrieveResponse, val member: MemberRetrieveResponse,
@field:JsonProperty("time") @field:JsonProperty("time")
val time: TimeCreateResponse, val time: TimeCreateResponse,
@field:JsonProperty("theme") @field:JsonProperty("theme")
val theme: ThemeRetrieveResponse, val theme: ThemeRetrieveResponse,
val status: ReservationStatus val status: ReservationStatus
) )
fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = ReservationRetrieveResponse( fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = ReservationRetrieveResponse(
id = this.id!!, id = this.id!!,
date = this.date, date = this.date,
member = this.member.toRetrieveResponse(), member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(), time = this.time.toCreateResponse(),
theme = this.theme.toResponse(), theme = this.theme.toResponse(),
status = this.reservationStatus status = this.reservationStatus
) )
data class ReservationRetrieveListResponse( data class ReservationRetrieveListResponse(
val reservations: List<ReservationRetrieveResponse> val reservations: List<ReservationRetrieveResponse>
) )

View File

@ -1,5 +1,6 @@
package roomescape.theme.business package roomescape.theme.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -10,44 +11,71 @@ import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.* import roomescape.theme.web.*
import java.time.LocalDate import java.time.LocalDate
private val log = KotlinLogging.logger {}
@Service @Service
class ThemeService( class ThemeService(
private val themeRepository: ThemeRepository private val themeRepository: ThemeRepository,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): ThemeEntity = themeRepository.findByIdOrNull(id) fun findById(id: Long): ThemeEntity {
?: throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) log.debug { "[ThemeService.findById] 테마 조회 시작: themeId=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[ThemeService.findById] 테마 조회 완료: themeId=$id" } }
?: run {
log.warn { "[ThemeService.findById] 테마 조회 실패: themeId=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemes(): ThemeRetrieveListResponse = themeRepository.findAll() fun findThemes(): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findThemes] 모든 테마 조회 시작" }
return themeRepository.findAll()
.also { log.info { "[ThemeService.findThemes] ${it.size}개의 테마 조회 완료" } }
.toResponse() .toResponse()
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findMostReservedThemes] 인기 테마 조회 시작: count=$count" }
val today = LocalDate.now() val today = LocalDate.now()
val startDate = today.minusDays(7) val startDate = today.minusDays(7)
val endDate = today.minusDays(1) val endDate = today.minusDays(1)
return themeRepository.findPopularThemes(startDate, endDate, count) return themeRepository.findPopularThemes(startDate, endDate, count)
.toResponse() .also { log.info { "[ThemeService.findMostReservedThemes] ${it.size} 개의 인기 테마 조회 완료" } }
.toResponse()
} }
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse { fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse {
log.debug { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
if (themeRepository.existsByName(request.name)) { if (themeRepository.existsByName(request.name)) {
log.info { "[ThemeService.createTheme] 테마 생성 실패(이름 중복): name=${request.name}" }
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
} }
val theme: ThemeEntity = request.toEntity() val theme: ThemeEntity = request.toEntity()
return themeRepository.save(theme).toResponse() return themeRepository.save(theme)
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } }
.toResponse()
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.debug { "[ThemeService.deleteTheme] 테마 삭제 시작: themeId=$id" }
if (themeRepository.isReservedTheme(id)) { if (themeRepository.isReservedTheme(id)) {
log.info { "[ThemeService.deleteTheme] 테마 삭제 실패(예약이 있는 테마): themeId=$id" }
throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
} }
themeRepository.deleteById(id) themeRepository.deleteById(id)
.also { log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: themeId=$id" } }
} }
} }

View File

@ -28,24 +28,24 @@ interface ThemeAPI {
@Operation(summary = "가장 많이 예약된 테마 조회") @Operation(summary = "가장 많이 예약된 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findMostReservedThemes( fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> ): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Admin @Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true), ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
) )
fun createTheme( fun createTheme(
@Valid @RequestBody request: ThemeCreateRequest, @Valid @RequestBody request: ThemeCreateRequest,
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>>
@Admin @Admin
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true), ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true),
) )
fun deleteTheme( fun deleteTheme(
@PathVariable id: Long @PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class ThemeErrorCode( enum class ThemeErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."), THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."),
THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."), THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."),

View File

@ -3,6 +3,6 @@ package roomescape.theme.exception
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class ThemeException( class ThemeException(
override val errorCode: ThemeErrorCode, override val errorCode: ThemeErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -5,11 +5,11 @@ import jakarta.persistence.*
@Entity @Entity
@Table(name = "themes") @Table(name = "themes")
class ThemeEntity( class ThemeEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
var name: String, var name: String,
var description: String, var description: String,
var thumbnail: String var thumbnail: String
) )

View File

@ -6,7 +6,8 @@ import java.time.LocalDate
interface ThemeRepository : JpaRepository<ThemeEntity, Long> { interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
@Query(value = """ @Query(
value = """
SELECT t SELECT t
FROM ThemeEntity t FROM ThemeEntity t
RIGHT JOIN ReservationEntity r ON t.id = r.theme.id RIGHT JOIN ReservationEntity r ON t.id = r.theme.id
@ -20,12 +21,14 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
@Query(value = """ @Query(
value = """
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM ReservationEntity r FROM ReservationEntity r
WHERE r.theme.id = :id WHERE r.theme.id = :id
) )
""") """
)
fun isReservedTheme(id: Long): Boolean fun isReservedTheme(id: Long): Boolean
} }

View File

@ -11,7 +11,7 @@ import java.net.URI
@RestController @RestController
class ThemeController( class ThemeController(
private val themeService: ThemeService private val themeService: ThemeService
) : ThemeAPI { ) : ThemeAPI {
@GetMapping("/themes") @GetMapping("/themes")
@ -23,7 +23,7 @@ class ThemeController(
@GetMapping("/themes/most-reserved-last-week") @GetMapping("/themes/most-reserved-last-week")
override fun findMostReservedThemes( override fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> {
val response: ThemeRetrieveListResponse = themeService.findMostReservedThemes(count) val response: ThemeRetrieveListResponse = themeService.findMostReservedThemes(count)
@ -32,17 +32,17 @@ class ThemeController(
@PostMapping("/themes") @PostMapping("/themes")
override fun createTheme( override fun createTheme(
@RequestBody @Valid request: ThemeCreateRequest @RequestBody @Valid request: ThemeCreateRequest
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> {
val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request) val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request)
return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}"))
.body(CommonApiResponse(themeResponse)) .body(CommonApiResponse(themeResponse))
} }
@DeleteMapping("/themes/{id}") @DeleteMapping("/themes/{id}")
override fun deleteTheme( override fun deleteTheme(
@PathVariable id: Long @PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
themeService.deleteTheme(id) themeService.deleteTheme(id)

View File

@ -7,45 +7,45 @@ import org.hibernate.validator.constraints.URL
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
data class ThemeCreateRequest( data class ThemeCreateRequest(
@NotBlank @NotBlank
@Size(max = 20) @Size(max = 20)
val name: String, val name: String,
@NotBlank @NotBlank
@Size(max = 100) @Size(max = 100)
val description: String, val description: String,
@URL @URL
@NotBlank @NotBlank
@Schema(description = "썸네일 이미지 주소(URL).") @Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String val thumbnail: String
) )
fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity( fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity(
name = this.name, name = this.name,
description = this.description, description = this.description,
thumbnail = this.thumbnail thumbnail = this.thumbnail
) )
data class ThemeRetrieveResponse( data class ThemeRetrieveResponse(
val id: Long, val id: Long,
val name: String, val name: String,
val description: String, val description: String,
@Schema(description = "썸네일 이미지 주소(URL).") @Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String val thumbnail: String
) )
fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse(
id = this.id!!, id = this.id!!,
name = this.name, name = this.name,
description = this.description, description = this.description,
thumbnail = this.thumbnail thumbnail = this.thumbnail
) )
data class ThemeRetrieveListResponse( data class ThemeRetrieveListResponse(
val themes: List<ThemeRetrieveResponse> val themes: List<ThemeRetrieveResponse>
) )
fun List<ThemeEntity>.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( fun List<ThemeEntity>.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse(
themes = this.map { it.toResponse() } themes = this.map { it.toResponse() }
) )

View File

@ -1,5 +1,6 @@
package roomescape.time.business package roomescape.time.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -13,50 +14,87 @@ import roomescape.time.web.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
private val log = KotlinLogging.logger {}
@Service @Service
class TimeService( class TimeService(
private val timeRepository: TimeRepository, private val timeRepository: TimeRepository,
private val reservationRepository: ReservationRepository private val reservationRepository: ReservationRepository,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): TimeEntity = timeRepository.findByIdOrNull(id) fun findById(id: Long): TimeEntity {
?: throw TimeException(TimeErrorCode.TIME_NOT_FOUND) log.debug { "[TimeService.findById] 시간 조회 시작: timeId=$id" }
return timeRepository.findByIdOrNull(id)
?.also { log.info { "[TimeService.findById] 시간 조회 완료: timeId=$id" } }
?: run {
log.warn { "[TimeService.findById] 시간 조회 실패: timeId=$id" }
throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll() fun findTimes(): TimeRetrieveListResponse {
log.debug { "[TimeService.findTimes] 모든 시간 조회 시작" }
return timeRepository.findAll()
.also { log.info { "[TimeService.findTimes] ${it.size}개의 시간 조회 완료" } }
.toResponse() .toResponse()
}
@Transactional @Transactional
fun createTime(request: TimeCreateRequest): TimeCreateResponse { fun createTime(request: TimeCreateRequest): TimeCreateResponse {
log.debug { "[TimeService.createTime] 시간 생성 시작: startAt=${request.startAt}" }
val startAt: LocalTime = request.startAt val startAt: LocalTime = request.startAt
if (timeRepository.existsByStartAt(startAt)) { if (timeRepository.existsByStartAt(startAt)) {
log.info { "[TimeService.createTime] 시간 생성 실패(시간 중복): startAt=$startAt" }
throw TimeException(TimeErrorCode.TIME_DUPLICATED) throw TimeException(TimeErrorCode.TIME_DUPLICATED)
} }
val time: TimeEntity = request.toEntity() val time: TimeEntity = request.toEntity()
return timeRepository.save(time)
return timeRepository.save(time).toCreateResponse() .also { log.info { "[TimeService.createTime] 시간 생성 완료: timeId=${it.id}" } }
.toCreateResponse()
} }
@Transactional @Transactional
fun deleteTime(id: Long) { fun deleteTime(id: Long) {
log.debug { "[TimeService.deleteTime] 시간 삭제 시작: timeId=$id" }
val time: TimeEntity = findById(id) val time: TimeEntity = findById(id)
log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 예약 조회 시작" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByTime(time) val reservations: List<ReservationEntity> = reservationRepository.findAllByTime(time)
log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 ${reservations.size} 개의 예약 조회 완료" }
if (reservations.isNotEmpty()) { if (reservations.isNotEmpty()) {
log.info { "[TimeService.deleteTime] 시간 삭제 실패(예약이 있는 시간): timeId=$id" }
throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED) throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
} }
timeRepository.delete(time) timeRepository.delete(time)
.also { log.info { "[TimeService.deleteTime] 시간 삭제 완료: timeId=$id" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findTimesWithAvailability(date: LocalDate, themeId: Long): TimeWithAvailabilityListResponse { fun findTimesWithAvailability(date: LocalDate, themeId: Long): TimeWithAvailabilityListResponse {
log.debug { "[TimeService.findTimesWithAvailability] 예약 가능 시간 조회 시작: date=$date, themeId=$themeId" }
log.debug { "[TimeService.findTimesWithAvailability] 모든 시간 조회 " }
val allTimes = timeRepository.findAll() val allTimes = timeRepository.findAll()
log.debug { "[TimeService.findTimesWithAvailability] ${allTimes.size}개의 시간 조회 완료" }
log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 인 모든 예약 조회 시작" }
val reservations: List<ReservationEntity> = reservationRepository.findByDateAndThemeId(date, themeId) val reservations: List<ReservationEntity> = reservationRepository.findByDateAndThemeId(date, themeId)
log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId${reservations.size} 개의 예약 조회 완료" }
return TimeWithAvailabilityListResponse(allTimes.map { time -> return TimeWithAvailabilityListResponse(allTimes.map { time ->
val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id } val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id }
TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable) TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable)
}) }).also {
log.info {
"[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 에 대한 예약 가능 여부가 담긴 모든 시간 조회 완료"
}
}
} }
} }

View File

@ -30,21 +30,21 @@ interface TimeAPI {
@Operation(summary = "시간 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "시간 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true))
fun createTime( fun createTime(
@Valid @RequestBody timeCreateRequest: TimeCreateRequest, @Valid @RequestBody timeCreateRequest: TimeCreateRequest,
): ResponseEntity<CommonApiResponse<TimeCreateResponse>> ): ResponseEntity<CommonApiResponse<TimeCreateResponse>>
@Admin @Admin
@Operation(summary = "시간 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "시간 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
fun deleteTime( fun deleteTime(
@PathVariable id: Long @PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findTimesWithAvailability( fun findTimesWithAvailability(
@RequestParam date: LocalDate, @RequestParam date: LocalDate,
@RequestParam themeId: Long @RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>> ): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>>
} }

View File

@ -4,9 +4,9 @@ import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
enum class TimeErrorCode( enum class TimeErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,
override val errorCode: String, override val errorCode: String,
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "TM001", "시간을 찾을 수 없어요."), TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "TM001", "시간을 찾을 수 없어요."),
TIME_DUPLICATED(HttpStatus.BAD_REQUEST, "TM002", "이미 같은 시간이 있어요."), TIME_DUPLICATED(HttpStatus.BAD_REQUEST, "TM002", "이미 같은 시간이 있어요."),

View File

@ -4,6 +4,6 @@ import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.common.exception.RoomescapeException
class TimeException( class TimeException(
override val errorCode: ErrorCode, override val errorCode: ErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)

View File

@ -6,8 +6,8 @@ import java.time.LocalTime
@Entity @Entity
@Table(name = "times") @Table(name = "times")
class TimeEntity( class TimeEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, var id: Long? = null,
var startAt: LocalTime var startAt: LocalTime
) )

View File

@ -11,7 +11,7 @@ import java.time.LocalDate
@RestController @RestController
class TimeController( class TimeController(
private val timeService: TimeService private val timeService: TimeService
) : TimeAPI { ) : TimeAPI {
@GetMapping("/times") @GetMapping("/times")
@ -23,13 +23,13 @@ class TimeController(
@PostMapping("/times") @PostMapping("/times")
override fun createTime( override fun createTime(
@Valid @RequestBody timeCreateRequest: TimeCreateRequest, @Valid @RequestBody timeCreateRequest: TimeCreateRequest,
): ResponseEntity<CommonApiResponse<TimeCreateResponse>> { ): ResponseEntity<CommonApiResponse<TimeCreateResponse>> {
val response: TimeCreateResponse = timeService.createTime(timeCreateRequest) val response: TimeCreateResponse = timeService.createTime(timeCreateRequest)
return ResponseEntity return ResponseEntity
.created(URI.create("/times/${response.id}")) .created(URI.create("/times/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
} }
@DeleteMapping("/times/{id}") @DeleteMapping("/times/{id}")
@ -41,8 +41,8 @@ class TimeController(
@GetMapping("/times/search") @GetMapping("/times/search")
override fun findTimesWithAvailability( override fun findTimesWithAvailability(
@RequestParam date: LocalDate, @RequestParam date: LocalDate,
@RequestParam themeId: Long @RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>> { ): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>> {
val response: TimeWithAvailabilityListResponse = timeService.findTimesWithAvailability(date, themeId) val response: TimeWithAvailabilityListResponse = timeService.findTimesWithAvailability(date, themeId)

View File

@ -6,52 +6,52 @@ import java.time.LocalTime
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.") @Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
data class TimeCreateRequest( data class TimeCreateRequest(
@Schema(description = "시간", type = "string", example = "09:00") @Schema(description = "시간", type = "string", example = "09:00")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt) fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") @Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
data class TimeCreateResponse( data class TimeCreateResponse(
@Schema(description = "시간 식별자") @Schema(description = "시간 식별자")
val id: Long, val id: Long,
@Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt) fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt)
data class TimeRetrieveResponse( data class TimeRetrieveResponse(
@Schema(description = "시간 식별자.") @Schema(description = "시간 식별자.")
val id: Long, val id: Long,
@Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeEntity.toResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt) fun TimeEntity.toResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt)
data class TimeRetrieveListResponse( data class TimeRetrieveListResponse(
val times: List<TimeRetrieveResponse> val times: List<TimeRetrieveResponse>
) )
fun List<TimeEntity>.toResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse( fun List<TimeEntity>.toResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse(
this.map { it.toResponse() } this.map { it.toResponse() }
) )
data class TimeWithAvailabilityResponse( data class TimeWithAvailabilityResponse(
@Schema(description = "시간 식별자") @Schema(description = "시간 식별자")
val id: Long, val id: Long,
@Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime, val startAt: LocalTime,
@Schema(description = "예약 가능 여부") @Schema(description = "예약 가능 여부")
val isAvailable: Boolean val isAvailable: Boolean
) )
data class TimeWithAvailabilityListResponse( data class TimeWithAvailabilityListResponse(
val times: List<TimeWithAvailabilityResponse> val times: List<TimeWithAvailabilityResponse>
) )

View File

@ -0,0 +1,40 @@
spring:
jpa:
show-sql: false
properties:
hibernate:
format_sql: true
ddl-auto: create-drop
defer-datasource-initialization: true
h2:
console:
enabled: true
path: /h2-console
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:database
username: sa
password:
security:
jwt:
token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
ttl-seconds: 1800000
payment:
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
read-timeout: 3
connect-timeout: 30
jdbc:
datasource-proxy:
enabled: true
include-parameter-values: false
query:
enable-logging: true
log-level: DEBUG
logger-name: query-logger
multiline: true
includes: connection,query,keys,fetch

View File

@ -1,33 +1,11 @@
spring: spring:
profiles:
active: ${ACTIVE_PROFILE:local}
jpa: jpa:
show-sql: false open-in-view: false
properties:
hibernate:
format_sql: true
ddl-auto: create-drop
defer-datasource-initialization: true
h2:
console:
enabled: true
path: /h2-console
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:database
username: sa
password:
security:
jwt:
token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
ttl-seconds: 1800000
payment: payment:
api-base-url: https://api.tosspayments.com api-base-url: https://api.tosspayments.com
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
read-timeout: 3
connect-timeout: 30
springdoc: springdoc:
swagger-ui: swagger-ui:

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<included>
<conversionRule conversionWord="maskedMessage"
class="roomescape.common.log.RoomescapeLogMaskingConverter"/>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %green(${PID:- }) --- [%15.15thread] %cyan(%-40logger{36}) : %maskedMessage%n%throwable"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="roomescape" level="debug" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
<logger name="query-logger" level="debug" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
</included>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<springProfile name="local">
<include resource="logback-local.xml"/>
</springProfile>
<springProfile name="default">
<include resource="logback-local.xml"/>
</springProfile>
</configuration>

View File

@ -10,7 +10,6 @@ import org.springframework.data.repository.findByIdOrNull
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.service.AuthService
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository

View File

@ -47,11 +47,11 @@ class JwtHandlerTest : FunSpec({
test("시크릿 키가 잘못된 경우 예외를 던진다.") { test("시크릿 키가 잘못된 경우 예외를 던진다.") {
val now = Date() val now = Date()
val invalidSignatureToken: String = Jwts.builder() val invalidSignatureToken: String = Jwts.builder()
.claim("memberId", memberId) .claim("memberId", memberId)
.issuedAt(now) .issuedAt(now)
.expiration(Date(now.time + JwtFixture.EXPIRATION_TIME)) .expiration(Date(now.time + JwtFixture.EXPIRATION_TIME))
.signWith(Keys.hmacShaKeyFor(JwtFixture.SECRET_KEY_STRING.substring(1).toByteArray())) .signWith(Keys.hmacShaKeyFor(JwtFixture.SECRET_KEY_STRING.substring(1).toByteArray()))
.compact() .compact()
shouldThrow<AuthException> { shouldThrow<AuthException> {
jwtHandler.getMemberIdFromToken(invalidSignatureToken) jwtHandler.getMemberIdFromToken(invalidSignatureToken)

View File

@ -6,8 +6,8 @@ import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.business.AuthService
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.service.AuthService
import roomescape.common.exception.CommonErrorCode import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
@ -133,6 +133,10 @@ class AuthControllerTest(
jwtHandler.getMemberIdFromToken(any()) jwtHandler.getMemberIdFromToken(any())
} returns 1L } returns 1L
every {
memberRepository.findByIdOrNull(1L)
} returns MemberFixture.create(id = 1L)
Then("정상 응답한다.") { Then("정상 응답한다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,

View File

@ -10,7 +10,7 @@ import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
class JacksonConfigTest( class JacksonConfigTest(
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
) : FunSpec({ ) : FunSpec({
context("날짜는 yyyy-mm-dd 형식이다.") { context("날짜는 yyyy-mm-dd 형식이다.") {

View File

@ -65,10 +65,10 @@ class PaymentServiceTest : FunSpec({
every { every {
canceledPaymentRepository.save(any()) canceledPaymentRepository.save(any())
} returns PaymentFixture.createCanceled( } returns PaymentFixture.createCanceled(
id = 1L, id = 1L,
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = "Test", cancelReason = "Test",
cancelAmount = paymentEntity.totalAmount, cancelAmount = paymentEntity.totalAmount,
) )
val result: PaymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId) val result: PaymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId)
@ -99,8 +99,8 @@ class PaymentServiceTest : FunSpec({
test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") { test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") {
val canceledPaymentEntity = PaymentFixture.createCanceled( val canceledPaymentEntity = PaymentFixture.createCanceled(
paymentKey = paymentKey, paymentKey = paymentKey,
canceledAt = canceledAt.minusMinutes(1) canceledAt = canceledAt.minusMinutes(1)
) )
every { every {

View File

@ -11,17 +11,17 @@ import roomescape.payment.web.PaymentCancelResponse
class PaymentCancelResponseDeserializerTest : StringSpec({ class PaymentCancelResponseDeserializerTest : StringSpec({
val objectMapper: ObjectMapper = jacksonObjectMapper().registerModule( val objectMapper: ObjectMapper = jacksonObjectMapper().registerModule(
SimpleModule().addDeserializer( SimpleModule().addDeserializer(
PaymentCancelResponse::class.java, PaymentCancelResponse::class.java,
PaymentCancelResponseDeserializer() PaymentCancelResponseDeserializer()
) )
) )
"결제 취소 응답을 역직렬화하여 PaymentCancelResponse 객체를 생성한다" { "결제 취소 응답을 역직렬화하여 PaymentCancelResponse 객체를 생성한다" {
val cancelResponseJson: String = SampleTossPaymentConst.cancelJson val cancelResponseJson: String = SampleTossPaymentConst.cancelJson
val cancelResponse: PaymentCancelResponse = objectMapper.readValue( val cancelResponse: PaymentCancelResponse = objectMapper.readValue(
cancelResponseJson, cancelResponseJson,
PaymentCancelResponse::class.java PaymentCancelResponse::class.java
) )
assertSoftly(cancelResponse) { assertSoftly(cancelResponse) {

View File

@ -15,10 +15,10 @@ object SampleTossPaymentConst {
val cancelReason: String = "테스트 결제 취소" val cancelReason: String = "테스트 결제 취소"
val paymentRequest: PaymentApproveRequest = PaymentApproveRequest( val paymentRequest: PaymentApproveRequest = PaymentApproveRequest(
paymentKey, paymentKey,
orderId, orderId,
amount, amount,
paymentType paymentType
) )
val paymentRequestJson: String = """ val paymentRequestJson: String = """
@ -31,9 +31,9 @@ object SampleTossPaymentConst {
""".trimIndent() """.trimIndent()
val cancelRequest: PaymentCancelRequest = PaymentCancelRequest( val cancelRequest: PaymentCancelRequest = PaymentCancelRequest(
paymentKey, paymentKey,
amount, amount,
cancelReason cancelReason
) )
val cancelRequestJson: String = """ val cancelRequestJson: String = """

View File

@ -21,8 +21,8 @@ import roomescape.payment.web.PaymentCancelResponse
@RestClientTest(TossPaymentClient::class) @RestClientTest(TossPaymentClient::class)
class TossPaymentClientTest( class TossPaymentClientTest(
@Autowired val client: TossPaymentClient, @Autowired val client: TossPaymentClient,
@Autowired val mockServer: MockRestServiceServer @Autowired val mockServer: MockRestServiceServer
) : FunSpec() { ) : FunSpec() {
init { init {
@ -40,9 +40,9 @@ class TossPaymentClientTest(
test("성공 응답") { test("성공 응답") {
commonAction().andRespond { commonAction().andRespond {
withSuccess() withSuccess()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.confirmJson) .body(SampleTossPaymentConst.confirmJson)
.createResponse(it) .createResponse(it)
} }
// when // when
@ -60,9 +60,9 @@ class TossPaymentClientTest(
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond { commonAction().andRespond {
withStatus(httpStatus) withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson) .body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it) .createResponse(it)
} }
// when // when
@ -99,9 +99,9 @@ class TossPaymentClientTest(
test("성공 응답") { test("성공 응답") {
commonAction().andRespond { commonAction().andRespond {
withSuccess() withSuccess()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.cancelJson) .body(SampleTossPaymentConst.cancelJson)
.createResponse(it) .createResponse(it)
} }
// when // when
@ -119,9 +119,9 @@ class TossPaymentClientTest(
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond { commonAction().andRespond {
withStatus(httpStatus) withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson) .body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it) .createResponse(it)
} }
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest

View File

@ -10,14 +10,14 @@ import java.util.*
@DataJpaTest @DataJpaTest
class CanceledPaymentRepositoryTest( class CanceledPaymentRepositoryTest(
@Autowired val canceledPaymentRepository: CanceledPaymentRepository, @Autowired val canceledPaymentRepository: CanceledPaymentRepository,
) : FunSpec() { ) : FunSpec() {
init { init {
context("paymentKey로 CanceledPaymentEntity 조회") { context("paymentKey로 CanceledPaymentEntity 조회") {
val paymentKey = "test-payment-key" val paymentKey = "test-payment-key"
beforeTest { beforeTest {
PaymentFixture.createCanceled(paymentKey = paymentKey) PaymentFixture.createCanceled(paymentKey = paymentKey)
.also { canceledPaymentRepository.save(it) } .also { canceledPaymentRepository.save(it) }
} }
test("정상 반환") { test("정상 반환") {
@ -30,7 +30,7 @@ class CanceledPaymentRepositoryTest(
test("null 반환") { test("null 반환") {
canceledPaymentRepository.findByPaymentKey(UUID.randomUUID().toString()) canceledPaymentRepository.findByPaymentKey(UUID.randomUUID().toString())
.also { it shouldBe null } .also { it shouldBe null }
} }
} }
} }

View File

@ -12,8 +12,8 @@ import roomescape.util.ReservationFixture
@DataJpaTest @DataJpaTest
class PaymentRepositoryTest( class PaymentRepositoryTest(
@Autowired val paymentRepository: PaymentRepository, @Autowired val paymentRepository: PaymentRepository,
@Autowired val entityManager: EntityManager @Autowired val entityManager: EntityManager
) : FunSpec() { ) : FunSpec() {
lateinit var reservation: ReservationEntity lateinit var reservation: ReservationEntity
@ -23,17 +23,17 @@ class PaymentRepositoryTest(
beforeTest { beforeTest {
reservation = setupReservation() reservation = setupReservation()
PaymentFixture.create(reservation = reservation) PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) } .also { paymentRepository.save(it) }
} }
test("true") { test("true") {
paymentRepository.existsByReservationId(reservation.id!!) paymentRepository.existsByReservationId(reservation.id!!)
.also { it shouldBe true } .also { it shouldBe true }
} }
test("false") { test("false") {
paymentRepository.existsByReservationId(reservation.id!! + 1L) paymentRepository.existsByReservationId(reservation.id!! + 1L)
.also { it shouldBe false } .also { it shouldBe false }
} }
} }
@ -43,19 +43,19 @@ class PaymentRepositoryTest(
beforeTest { beforeTest {
reservation = setupReservation() reservation = setupReservation()
paymentKey = PaymentFixture.create(reservation = reservation) paymentKey = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) } .also { paymentRepository.save(it) }
.paymentKey .paymentKey
} }
test("정상 반환") { test("정상 반환") {
paymentRepository.findPaymentKeyByReservationId(reservation.id!!) paymentRepository.findPaymentKeyByReservationId(reservation.id!!)
?.let { it shouldBe paymentKey } ?.let { it shouldBe paymentKey }
?: throw AssertionError("Unexpected null value") ?: throw AssertionError("Unexpected null value")
} }
test("null 반환") { test("null 반환") {
paymentRepository.findPaymentKeyByReservationId(reservation.id!! + 1) paymentRepository.findPaymentKeyByReservationId(reservation.id!! + 1)
.also { it shouldBe null } .also { it shouldBe null }
} }
} }
@ -65,27 +65,27 @@ class PaymentRepositoryTest(
beforeTest { beforeTest {
reservation = setupReservation() reservation = setupReservation()
payment = PaymentFixture.create(reservation = reservation) payment = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) } .also { paymentRepository.save(it) }
} }
test("정상 반환") { test("정상 반환") {
paymentRepository.findByPaymentKey(payment.paymentKey) paymentRepository.findByPaymentKey(payment.paymentKey)
?.also { ?.also {
assertSoftly(it) { assertSoftly(it) {
this.id shouldBe payment.id this.id shouldBe payment.id
this.orderId shouldBe payment.orderId this.orderId shouldBe payment.orderId
this.paymentKey shouldBe payment.paymentKey this.paymentKey shouldBe payment.paymentKey
this.totalAmount shouldBe payment.totalAmount this.totalAmount shouldBe payment.totalAmount
this.reservation.id shouldBe payment.reservation.id this.reservation.id shouldBe payment.reservation.id
this.approvedAt shouldBe payment.approvedAt this.approvedAt shouldBe payment.approvedAt
}
} }
?: throw AssertionError("Unexpected null value") }
?: throw AssertionError("Unexpected null value")
} }
test("null 반환") { test("null 반환") {
paymentRepository.findByPaymentKey("non-existent-key") paymentRepository.findByPaymentKey("non-existent-key")
.also { it shouldBe null } .also { it shouldBe null }
} }
} }
} }

View File

@ -27,10 +27,10 @@ class ReservationServiceTest : FunSpec({
val memberService: MemberService = mockk() val memberService: MemberService = mockk()
val themeService: ThemeService = mockk() val themeService: ThemeService = mockk()
val reservationService = ReservationService( val reservationService = ReservationService(
reservationRepository, reservationRepository,
timeService, timeService,
memberService, memberService,
themeService themeService
) )
context("예약을 추가할 때") { context("예약을 추가할 때") {
@ -64,7 +64,7 @@ class ReservationServiceTest : FunSpec({
test("지난 날짜이면 예외를 던진다.") { test("지난 날짜이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy( val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now().minusDays(1) date = LocalDate.now().minusDays(1)
) )
every { every {
@ -80,13 +80,13 @@ class ReservationServiceTest : FunSpec({
test("지난 시간이면 예외를 던진다.") { test("지난 시간이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy( val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(), date = LocalDate.now(),
) )
every { every {
timeService.findById(reservationRequest.timeId) timeService.findById(reservationRequest.timeId)
} returns TimeFixture.create( } returns TimeFixture.create(
startAt = LocalTime.now().minusMinutes(1) startAt = LocalTime.now().minusMinutes(1)
) )
shouldThrow<ReservationException> { shouldThrow<ReservationException> {
@ -101,9 +101,9 @@ class ReservationServiceTest : FunSpec({
context("예약 대기를 걸 때") { context("예약 대기를 걸 때") {
test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") { test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy( val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(), date = LocalDate.now(),
themeId = 1L, themeId = 1L,
timeId = 1L, timeId = 1L,
) )
every { every {
@ -112,9 +112,9 @@ class ReservationServiceTest : FunSpec({
shouldThrow<ReservationException> { shouldThrow<ReservationException> {
val waitingRequest = ReservationFixture.createWaitingRequest( val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date, date = reservationRequest.date,
themeId = reservationRequest.themeId, themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId timeId = reservationRequest.timeId
) )
reservationService.createWaiting(waitingRequest, 1L) reservationService.createWaiting(waitingRequest, 1L)
}.also { }.also {
@ -140,8 +140,8 @@ class ReservationServiceTest : FunSpec({
test("대기중인 해당 예약이 이미 확정된 상태라면 예외를 던진다.") { test("대기중인 해당 예약이 이미 확정된 상태라면 예외를 던진다.") {
val alreadyConfirmed = ReservationFixture.create( val alreadyConfirmed = ReservationFixture.create(
id = reservationId, id = reservationId,
status = ReservationStatus.CONFIRMED status = ReservationStatus.CONFIRMED
) )
every { every {
reservationRepository.findByIdOrNull(reservationId) reservationRepository.findByIdOrNull(reservationId)
@ -156,9 +156,9 @@ class ReservationServiceTest : FunSpec({
test("타인의 대기를 취소하려고 하면 예외를 던진다.") { test("타인의 대기를 취소하려고 하면 예외를 던진다.") {
val otherMembersWaiting = ReservationFixture.create( val otherMembersWaiting = ReservationFixture.create(
id = reservationId, id = reservationId,
member = MemberFixture.create(id = member.id!! + 1L), member = MemberFixture.create(id = member.id!! + 1L),
status = ReservationStatus.WAITING status = ReservationStatus.WAITING
) )
every { every {
@ -180,10 +180,10 @@ class ReservationServiceTest : FunSpec({
shouldThrow<ReservationException> { shouldThrow<ReservationException> {
reservationService.searchReservations( reservationService.searchReservations(
null, null,
null, null,
startFrom, startFrom,
endAt endAt
) )
}.also { }.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
@ -263,8 +263,8 @@ class ReservationServiceTest : FunSpec({
test("이미 확정된 예약이면 예외를 던진다.") { test("이미 확정된 예약이면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN) val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservation = ReservationFixture.create( val reservation = ReservationFixture.create(
id = 1L, id = 1L,
status = ReservationStatus.CONFIRMED status = ReservationStatus.CONFIRMED
) )
every { every {

View File

@ -22,27 +22,27 @@ class ReservationWithPaymentServiceTest : FunSpec({
val paymentService: PaymentService = mockk() val paymentService: PaymentService = mockk()
val reservationWithPaymentService = ReservationWithPaymentService( val reservationWithPaymentService = ReservationWithPaymentService(
reservationService = reservationService, reservationService = reservationService,
paymentService = paymentService paymentService = paymentService
) )
val reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest = ReservationFixture.createRequest() val reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest = ReservationFixture.createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse() val paymentApproveResponse = PaymentFixture.createApproveResponse()
val memberId = 1L val memberId = 1L
val reservationEntity: ReservationEntity = ReservationFixture.create( val reservationEntity: ReservationEntity = ReservationFixture.create(
id = 1L, id = 1L,
date = reservationCreateWithPaymentRequest.date, date = reservationCreateWithPaymentRequest.date,
time = TimeFixture.create(id = reservationCreateWithPaymentRequest.timeId), time = TimeFixture.create(id = reservationCreateWithPaymentRequest.timeId),
theme = ThemeFixture.create(id = reservationCreateWithPaymentRequest.themeId), theme = ThemeFixture.create(id = reservationCreateWithPaymentRequest.themeId),
member = MemberFixture.create(id = memberId), member = MemberFixture.create(id = memberId),
status = ReservationStatus.CONFIRMED status = ReservationStatus.CONFIRMED
) )
val paymentEntity: PaymentEntity = PaymentFixture.create( val paymentEntity: PaymentEntity = PaymentFixture.create(
id = 1L, id = 1L,
orderId = reservationCreateWithPaymentRequest.orderId, orderId = reservationCreateWithPaymentRequest.orderId,
paymentKey = reservationCreateWithPaymentRequest.paymentKey, paymentKey = reservationCreateWithPaymentRequest.paymentKey,
totalAmount = reservationCreateWithPaymentRequest.amount, totalAmount = reservationCreateWithPaymentRequest.amount,
reservation = reservationEntity, reservation = reservationEntity,
) )
context("addReservationWithPayment") { context("addReservationWithPayment") {
@ -56,9 +56,9 @@ class ReservationWithPaymentServiceTest : FunSpec({
} returns paymentEntity.toCreateResponse() } returns paymentEntity.toCreateResponse()
val result: ReservationRetrieveResponse = reservationWithPaymentService.createReservationAndPayment( val result: ReservationRetrieveResponse = reservationWithPaymentService.createReservationAndPayment(
request = reservationCreateWithPaymentRequest, request = reservationCreateWithPaymentRequest,
paymentInfo = paymentApproveResponse, paymentInfo = paymentApproveResponse,
memberId = memberId memberId = memberId
) )
assertSoftly(result) { assertSoftly(result) {
@ -75,9 +75,9 @@ class ReservationWithPaymentServiceTest : FunSpec({
context("removeReservationWithPayment") { context("removeReservationWithPayment") {
test("예약 및 결제 정보를 삭제하고, 결제 취소 정보를 저장한다.") { test("예약 및 결제 정보를 삭제하고, 결제 취소 정보를 저장한다.") {
val paymentCancelRequest: PaymentCancelRequest = PaymentFixture.createCancelRequest().copy( val paymentCancelRequest: PaymentCancelRequest = PaymentFixture.createCancelRequest().copy(
paymentKey = paymentEntity.paymentKey, paymentKey = paymentEntity.paymentKey,
amount = paymentEntity.totalAmount, amount = paymentEntity.totalAmount,
cancelReason = "고객 요청" cancelReason = "고객 요청"
) )
every { every {
@ -89,8 +89,8 @@ class ReservationWithPaymentServiceTest : FunSpec({
} just Runs } just Runs
val result: PaymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment( val result: PaymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(
reservationId = reservationEntity.id!!, reservationId = reservationEntity.id!!,
memberId = reservationEntity.member.id!! memberId = reservationEntity.member.id!!
) )
result shouldBe paymentCancelRequest result shouldBe paymentCancelRequest

View File

@ -17,8 +17,8 @@ import roomescape.util.TimeFixture
@DataJpaTest @DataJpaTest
class ReservationRepositoryTest( class ReservationRepositoryTest(
val entityManager: EntityManager, val entityManager: EntityManager,
val reservationRepository: ReservationRepository, val reservationRepository: ReservationRepository,
) : FunSpec() { ) : FunSpec() {
init { init {
context("findByTime") { context("findByTime") {
@ -26,10 +26,12 @@ class ReservationRepositoryTest(
beforeTest { beforeTest {
listOf( listOf(
ReservationFixture.create(time = time), ReservationFixture.create(time = time),
ReservationFixture.create(time = TimeFixture.create( ReservationFixture.create(
startAt = time.startAt.plusSeconds(1) time = TimeFixture.create(
)) startAt = time.startAt.plusSeconds(1)
)
)
).forEach { ).forEach {
persistReservation(it) persistReservation(it)
} }
@ -64,9 +66,9 @@ class ReservationRepositoryTest(
} }
listOf( listOf(
ReservationFixture.create(date = date, theme = theme1), ReservationFixture.create(date = date, theme = theme1),
ReservationFixture.create(date = date.plusDays(1), theme = theme1), ReservationFixture.create(date = date.plusDays(1), theme = theme1),
ReservationFixture.create(date = date, theme = theme2), ReservationFixture.create(date = date, theme = theme2),
).forEach { ).forEach {
entityManager.persist(it.time) entityManager.persist(it.time)
entityManager.persist(it.member) entityManager.persist(it.member)
@ -124,9 +126,10 @@ class ReservationRepositoryTest(
persistReservation(it) persistReservation(it)
} }
confirmedPaymentRequired = ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also { confirmedPaymentRequired =
persistReservation(it) ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also {
} persistReservation(it)
}
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
@ -134,7 +137,7 @@ class ReservationRepositoryTest(
test("예약이 없으면 false를 반환한다.") { test("예약이 없으면 false를 반환한다.") {
val maxId: Long = listOf(waiting, confirmed, confirmedPaymentRequired) val maxId: Long = listOf(waiting, confirmed, confirmedPaymentRequired)
.maxOfOrNull { it.id ?: 0L } ?: 0L .maxOfOrNull { it.id ?: 0L } ?: 0L
reservationRepository.isExistConfirmedReservation(maxId + 1L) shouldBe false reservationRepository.isExistConfirmedReservation(maxId + 1L) shouldBe false
} }
@ -161,14 +164,15 @@ class ReservationRepositoryTest(
test("결제 정보를 포함한 회원의 예약 목록을 반환한다.") { test("결제 정보를 포함한 회원의 예약 목록을 반환한다.") {
val payment: PaymentEntity = PaymentFixture.create( val payment: PaymentEntity = PaymentFixture.create(
reservation = reservation reservation = reservation
).also { ).also {
entityManager.persist(it) entityManager.persist(it)
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
} }
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllByMemberId(reservation.member.id!!) val result: List<MyReservationRetrieveResponse> =
reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1 result shouldHaveSize 1
assertSoftly(result.first()) { assertSoftly(result.first()) {
@ -179,7 +183,8 @@ class ReservationRepositoryTest(
} }
test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") { test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") {
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllByMemberId(reservation.member.id!!) val result: List<MyReservationRetrieveResponse> =
reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1 result shouldHaveSize 1
assertSoftly(result.first()) { assertSoftly(result.first()) {

View File

@ -17,8 +17,8 @@ import java.time.LocalDate
@DataJpaTest @DataJpaTest
class ReservationSearchSpecificationTest( class ReservationSearchSpecificationTest(
val entityManager: EntityManager, val entityManager: EntityManager,
val reservationRepository: ReservationRepository val reservationRepository: ReservationRepository
) : StringSpec() { ) : StringSpec() {
init { init {
@ -31,8 +31,8 @@ class ReservationSearchSpecificationTest(
"동일한 테마의 예약을 조회한다" { "동일한 테마의 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.sameThemeId(theme.id) .sameThemeId(theme.id)
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -44,8 +44,8 @@ class ReservationSearchSpecificationTest(
"동일한 회원의 예약을 조회한다" { "동일한 회원의 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.sameMemberId(member.id) .sameMemberId(member.id)
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -57,8 +57,8 @@ class ReservationSearchSpecificationTest(
"동일한 예약 시간의 예약을 조회한다" { "동일한 예약 시간의 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.sameTimeId(time.id) .sameTimeId(time.id)
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -70,8 +70,8 @@ class ReservationSearchSpecificationTest(
"동일한 날짜의 예약을 조회한다" { "동일한 날짜의 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.sameDate(LocalDate.now()) .sameDate(LocalDate.now())
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -83,8 +83,8 @@ class ReservationSearchSpecificationTest(
"확정 상태인 예약을 조회한다" { "확정 상태인 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.confirmed() .confirmed()
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -96,8 +96,8 @@ class ReservationSearchSpecificationTest(
"대기 상태인 예약을 조회한다" { "대기 상태인 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.waiting() .waiting()
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -109,8 +109,8 @@ class ReservationSearchSpecificationTest(
"예약 날짜가 오늘 이후인 예약을 조회한다" { "예약 날짜가 오늘 이후인 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.dateStartFrom(LocalDate.now()) .dateStartFrom(LocalDate.now())
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -122,8 +122,8 @@ class ReservationSearchSpecificationTest(
"예약 날짜가 내일 이전인 예약을 조회한다" { "예약 날짜가 내일 이전인 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.dateEndAt(LocalDate.now().plusDays(1)) .dateEndAt(LocalDate.now().plusDays(1))
.build() .build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec) val results: List<ReservationEntity> = reservationRepository.findAll(spec)
@ -145,31 +145,31 @@ class ReservationSearchSpecificationTest(
} }
confirmedNow = ReservationFixture.create( confirmedNow = ReservationFixture.create(
time = time, time = time,
member = member, member = member,
theme = theme, theme = theme,
date = LocalDate.now(), date = LocalDate.now(),
status = ReservationStatus.CONFIRMED status = ReservationStatus.CONFIRMED
).also { ).also {
entityManager.persist(it) entityManager.persist(it)
} }
confirmedNotPaidYesterday = ReservationFixture.create( confirmedNotPaidYesterday = ReservationFixture.create(
time = time, time = time,
member = member, member = member,
theme = theme, theme = theme,
date = LocalDate.now().minusDays(1), date = LocalDate.now().minusDays(1),
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
).also { ).also {
entityManager.persist(it) entityManager.persist(it)
} }
waitingTomorrow = ReservationFixture.create( waitingTomorrow = ReservationFixture.create(
time = time, time = time,
member = member, member = member,
theme = theme, theme = theme,
date = LocalDate.now().plusDays(1), date = LocalDate.now().plusDays(1),
status = ReservationStatus.WAITING status = ReservationStatus.WAITING
).also { ).also {
entityManager.persist(it) entityManager.persist(it)
} }

View File

@ -61,9 +61,9 @@ class ThemeServiceTest : FunSpec({
context("save") { context("save") {
val request = ThemeCreateRequest( val request = ThemeCreateRequest(
name = "New Theme", name = "New Theme",
description = "Description", description = "Description",
thumbnail = "http://example.com/thumbnail.jpg" thumbnail = "http://example.com/thumbnail.jpg"
) )
test("저장 성공") { test("저장 성공") {
@ -74,10 +74,10 @@ class ThemeServiceTest : FunSpec({
every { every {
themeRepository.save(any()) themeRepository.save(any())
} returns ThemeFixture.create( } returns ThemeFixture.create(
id = 1L, id = 1L,
name = request.name, name = request.name,
description = request.description, description = request.description,
thumbnail = request.thumbnail thumbnail = request.thumbnail
) )
val response: ThemeRetrieveResponse = themeService.createTheme(request) val response: ThemeRetrieveResponse = themeService.createTheme(request)

View File

@ -10,8 +10,8 @@ import java.time.LocalDate
@DataJpaTest @DataJpaTest
class ThemeRepositoryTest( class ThemeRepositoryTest(
val themeRepository: ThemeRepository, val themeRepository: ThemeRepository,
val entityManager: EntityManager val entityManager: EntityManager
) : FunSpec() { ) : FunSpec() {
init { init {
@ -19,65 +19,65 @@ class ThemeRepositoryTest(
beforeTest { beforeTest {
for (i in 1..10) { for (i in 1..10) {
TestThemeCreateUtil.createThemeWithReservations( TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "테마$i", name = "테마$i",
reservedCount = i, reservedCount = i,
date = LocalDate.now().minusDays(i.toLong()), date = LocalDate.now().minusDays(i.toLong()),
) )
} }
} }
test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회한다.") { test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회한다.") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(1), LocalDate.now().minusDays(1),
5 5
).also { themes -> ).also { themes ->
themes.size shouldBe 5 themes.size shouldBe 5
themes.map { it.name } shouldContainInOrder listOf( themes.map { it.name } shouldContainInOrder listOf(
"테마10", "테마9", "테마8", "테마7", "테마6" "테마10", "테마9", "테마8", "테마7", "테마6"
) )
} }
} }
test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회한다.") { test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회한다.") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(8), LocalDate.now().minusDays(8),
LocalDate.now().minusDays(5), LocalDate.now().minusDays(5),
3 3
).also { themes -> ).also { themes ->
themes.size shouldBe 3 themes.size shouldBe 3
themes.map { it.name } shouldContainInOrder listOf( themes.map { it.name } shouldContainInOrder listOf(
"테마8", "테마7", "테마6" "테마8", "테마7", "테마6"
) )
} }
} }
test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회한다.") { test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회한다.") {
TestThemeCreateUtil.createThemeWithReservations( TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "테마11", name = "테마11",
reservedCount = 5, reservedCount = 5,
date = LocalDate.now().minusDays(5), date = LocalDate.now().minusDays(5),
) )
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(6), LocalDate.now().minusDays(6),
LocalDate.now().minusDays(4), LocalDate.now().minusDays(4),
5 5
).also { themes -> ).also { themes ->
themes.size shouldBe 4 themes.size shouldBe 4
themes.map { it.name } shouldContainInOrder listOf( themes.map { it.name } shouldContainInOrder listOf(
"테마6", "테마5", "테마11", "테마4" "테마6", "테마5", "테마11", "테마4"
) )
} }
} }
test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환한다.") { test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환한다.") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(6), LocalDate.now().minusDays(6),
10 10
).also { themes -> ).also { themes ->
themes.size shouldBe 5 themes.size shouldBe 5
} }
@ -85,9 +85,9 @@ class ThemeRepositoryTest(
test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환한다.") { test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환한다.") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(1), LocalDate.now().minusDays(1),
15 15
).also { themes -> ).also { themes ->
themes.size shouldBe 10 themes.size shouldBe 10
} }
@ -95,9 +95,9 @@ class ThemeRepositoryTest(
test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트를 반환한다.") { test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트를 반환한다.") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().plusDays(1), LocalDate.now().plusDays(1),
LocalDate.now().plusDays(10), LocalDate.now().plusDays(10),
5 5
).also { themes -> ).also { themes ->
themes.size shouldBe 0 themes.size shouldBe 0
} }
@ -107,10 +107,10 @@ class ThemeRepositoryTest(
val themeName = "test-theme" val themeName = "test-theme"
beforeTest { beforeTest {
TestThemeCreateUtil.createThemeWithReservations( TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = themeName, name = themeName,
reservedCount = 0, reservedCount = 0,
date = LocalDate.now() date = LocalDate.now()
) )
} }
test("테마 이름이 존재하면 true를 반환한다.") { test("테마 이름이 존재하면 true를 반환한다.") {
@ -125,20 +125,20 @@ class ThemeRepositoryTest(
context("isReservedTheme") { context("isReservedTheme") {
test("테마가 예약 중이면 true를 반환한다.") { test("테마가 예약 중이면 true를 반환한다.") {
val theme = TestThemeCreateUtil.createThemeWithReservations( val theme = TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "예약된 테마", name = "예약된 테마",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now() date = LocalDate.now()
) )
themeRepository.isReservedTheme(theme.id!!) shouldBe true themeRepository.isReservedTheme(theme.id!!) shouldBe true
} }
test("테마가 예약 중이 아니면 false를 반환한다.") { test("테마가 예약 중이 아니면 false를 반환한다.") {
val theme = TestThemeCreateUtil.createThemeWithReservations( val theme = TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "예약되지 않은 테마", name = "예약되지 않은 테마",
reservedCount = 0, reservedCount = 0,
date = LocalDate.now() date = LocalDate.now()
) )
themeRepository.isReservedTheme(theme.id!!) shouldBe false themeRepository.isReservedTheme(theme.id!!) shouldBe false
} }

View File

@ -14,25 +14,25 @@ import java.time.LocalTime
object TestThemeCreateUtil { object TestThemeCreateUtil {
fun createThemeWithReservations( fun createThemeWithReservations(
entityManager: EntityManager, entityManager: EntityManager,
name: String, name: String,
reservedCount: Int, reservedCount: Int,
date: LocalDate, date: LocalDate,
): ThemeEntity { ): ThemeEntity {
val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) } val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) }
val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) } val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) }
for (i in 1..reservedCount) { for (i in 1..reservedCount) {
val time: TimeEntity = TimeFixture.create( val time: TimeEntity = TimeFixture.create(
startAt = LocalTime.now().plusMinutes(i.toLong()) startAt = LocalTime.now().plusMinutes(i.toLong())
).also { entityManager.persist(it) } ).also { entityManager.persist(it) }
ReservationFixture.create( ReservationFixture.create(
date = date, date = date,
theme = themeEntity, theme = themeEntity,
member = member, member = member,
time = time, time = time,
status = ReservationStatus.CONFIRMED status = ReservationStatus.CONFIRMED
).also { entityManager.persist(it) } ).also { entityManager.persist(it) }
} }

View File

@ -17,9 +17,9 @@ import kotlin.random.Random
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MostReservedThemeApiTest( class MostReservedThemeApiTest(
@LocalServerPort val port: Int, @LocalServerPort val port: Int,
val transactionTemplate: TransactionTemplate, val transactionTemplate: TransactionTemplate,
val entityManager: EntityManager, val entityManager: EntityManager,
) : FunSpec({ ) : FunSpec({
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC)) extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC))
}) { }) {
@ -29,19 +29,19 @@ class MostReservedThemeApiTest(
// 지난 7일간 예약된 테마 10개 생성 // 지난 7일간 예약된 테마 10개 생성
for (i in 1..10) { for (i in 1..10) {
TestThemeCreateUtil.createThemeWithReservations( TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "테마$i", name = "테마$i",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now().minusDays(Random.nextLong(1, 7)) date = LocalDate.now().minusDays(Random.nextLong(1, 7))
) )
} }
// 8일 전 예약된 테마 1개 생성 // 8일 전 예약된 테마 1개 생성
TestThemeCreateUtil.createThemeWithReservations( TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager, entityManager = entityManager,
name = "테마11", name = "테마11",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now().minusDays(8) date = LocalDate.now().minusDays(8)
) )
} }
} }

View File

@ -22,8 +22,8 @@ class TimeServiceTest : FunSpec({
val reservationRepository: ReservationRepository = mockk() val reservationRepository: ReservationRepository = mockk()
val timeService = TimeService( val timeService = TimeService(
timeRepository = timeRepository, timeRepository = timeRepository,
reservationRepository = reservationRepository reservationRepository = reservationRepository
) )
context("findTimeById") { context("findTimeById") {
@ -46,8 +46,8 @@ class TimeServiceTest : FunSpec({
test("정상 저장") { test("정상 저장") {
every { timeRepository.existsByStartAt(request.startAt) } returns false every { timeRepository.existsByStartAt(request.startAt) } returns false
every { timeRepository.save(any()) } returns TimeFixture.create( every { timeRepository.save(any()) } returns TimeFixture.create(
id = 1L, id = 1L,
startAt = request.startAt startAt = request.startAt
) )
val response = timeService.createTime(request) val response = timeService.createTime(request)

View File

@ -9,8 +9,8 @@ import java.time.LocalTime
@DataJpaTest @DataJpaTest
class TimeRepositoryTest( class TimeRepositoryTest(
val entityManager: EntityManager, val entityManager: EntityManager,
val timeRepository: TimeRepository, val timeRepository: TimeRepository,
) : FunSpec({ ) : FunSpec({
context("existsByStartAt") { context("existsByStartAt") {

View File

@ -12,8 +12,8 @@ import org.springframework.stereotype.Component
@Component @Component
class DatabaseCleaner( class DatabaseCleaner(
val entityManager: EntityManager, val entityManager: EntityManager,
val jdbcTemplate: JdbcTemplate, val jdbcTemplate: JdbcTemplate,
) { ) {
val tables: List<String> by lazy { val tables: List<String> by lazy {
jdbcTemplate.query("SHOW TABLES") { rs, _ -> jdbcTemplate.query("SHOW TABLES") { rs, _ ->
@ -38,7 +38,7 @@ enum class CleanerMode {
} }
class DatabaseCleanerExtension( class DatabaseCleanerExtension(
private val mode: CleanerMode private val mode: CleanerMode
) : AfterTestListener, AfterSpecListener { ) : AfterTestListener, AfterSpecListener {
override suspend fun afterTest(testCase: TestCase, result: TestResult) { override suspend fun afterTest(testCase: TestCase, result: TestResult) {
super.afterTest(testCase, result) super.afterTest(testCase, result)
@ -58,7 +58,7 @@ class DatabaseCleanerExtension(
private suspend fun getCleaner(): DatabaseCleaner { private suspend fun getCleaner(): DatabaseCleaner {
return testContextManager().testContext return testContextManager().testContext
.applicationContext .applicationContext
.getBean(DatabaseCleaner::class.java) .getBean(DatabaseCleaner::class.java)
} }
} }

View File

@ -24,88 +24,88 @@ object MemberFixture {
const val NOT_LOGGED_IN_USERID: Long = 0 const val NOT_LOGGED_IN_USERID: Long = 0
fun create( fun create(
id: Long? = null, id: Long? = null,
name: String = "sangdol", name: String = "sangdol",
account: String = "default", account: String = "default",
password: String = "password", password: String = "password",
role: Role = Role.ADMIN role: Role = Role.ADMIN
): MemberEntity = MemberEntity(id, name, "$account@email.com", password, role) ): MemberEntity = MemberEntity(id, name, "$account@email.com", password, role)
fun admin(): MemberEntity = create( fun admin(): MemberEntity = create(
id = 2L, id = 2L,
account = "admin", account = "admin",
role = Role.ADMIN role = Role.ADMIN
) )
fun adminLoginRequest(): LoginRequest = LoginRequest( fun adminLoginRequest(): LoginRequest = LoginRequest(
email = admin().email, email = admin().email,
password = admin().password password = admin().password
) )
fun user(): MemberEntity = create( fun user(): MemberEntity = create(
id = 1L, id = 1L,
account = "user", account = "user",
role = Role.MEMBER role = Role.MEMBER
) )
fun userLoginRequest(): LoginRequest = LoginRequest( fun userLoginRequest(): LoginRequest = LoginRequest(
email = user().email, email = user().email,
password = user().password password = user().password
) )
} }
object TimeFixture { object TimeFixture {
fun create( fun create(
id: Long? = null, id: Long? = null,
startAt: LocalTime = LocalTime.now().plusHours(1), startAt: LocalTime = LocalTime.now().plusHours(1),
): TimeEntity = TimeEntity(id, startAt) ): TimeEntity = TimeEntity(id, startAt)
} }
object ThemeFixture { object ThemeFixture {
fun create( fun create(
id: Long? = null, id: Long? = null,
name: String = "Default Theme", name: String = "Default Theme",
description: String = "Default Description", description: String = "Default Description",
thumbnail: String = "https://example.com/default-thumbnail.jpg" thumbnail: String = "https://example.com/default-thumbnail.jpg"
): ThemeEntity = ThemeEntity(id, name, description, thumbnail) ): ThemeEntity = ThemeEntity(id, name, description, thumbnail)
} }
object ReservationFixture { object ReservationFixture {
fun create( fun create(
id: Long? = null, id: Long? = null,
date: LocalDate = LocalDate.now().plusWeeks(1), date: LocalDate = LocalDate.now().plusWeeks(1),
theme: ThemeEntity = ThemeFixture.create(), theme: ThemeEntity = ThemeFixture.create(),
time: TimeEntity = TimeFixture.create(), time: TimeEntity = TimeFixture.create(),
member: MemberEntity = MemberFixture.create(), member: MemberEntity = MemberFixture.create(),
status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
): ReservationEntity = ReservationEntity(id, date, time, theme, member, status) ): ReservationEntity = ReservationEntity(id, date, time, theme, member, status)
fun createRequest( fun createRequest(
date: LocalDate = LocalDate.now().plusWeeks(1), date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L, themeId: Long = 1L,
timeId: Long = 1L, timeId: Long = 1L,
paymentKey: String = "paymentKey", paymentKey: String = "paymentKey",
orderId: String = "orderId", orderId: String = "orderId",
amount: Long = 10000L, amount: Long = 10000L,
paymentType: String = "NORMAL", paymentType: String = "NORMAL",
): ReservationCreateWithPaymentRequest = ReservationCreateWithPaymentRequest( ): ReservationCreateWithPaymentRequest = ReservationCreateWithPaymentRequest(
date = date, date = date,
timeId = timeId, timeId = timeId,
themeId = themeId, themeId = themeId,
paymentKey = paymentKey, paymentKey = paymentKey,
orderId = orderId, orderId = orderId,
amount = amount, amount = amount,
paymentType = paymentType paymentType = paymentType
) )
fun createWaitingRequest( fun createWaitingRequest(
date: LocalDate = LocalDate.now().plusWeeks(1), date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L, themeId: Long = 1L,
timeId: Long = 1L timeId: Long = 1L
): WaitingCreateRequest = WaitingCreateRequest( ): WaitingCreateRequest = WaitingCreateRequest(
date = date, date = date,
timeId = timeId, timeId = timeId,
themeId = themeId themeId = themeId
) )
} }
@ -114,8 +114,8 @@ object JwtFixture {
const val EXPIRATION_TIME: Long = 1000 * 60 * 60 const val EXPIRATION_TIME: Long = 1000 * 60 * 60
fun create( fun create(
secretKey: String = SECRET_KEY_STRING, secretKey: String = SECRET_KEY_STRING,
expirationTime: Long = EXPIRATION_TIME expirationTime: Long = EXPIRATION_TIME
): JwtHandler = JwtHandler(secretKey, expirationTime) ): JwtHandler = JwtHandler(secretKey, expirationTime)
} }
@ -125,63 +125,63 @@ object PaymentFixture {
const val AMOUNT: Long = 10000L const val AMOUNT: Long = 10000L
fun create( fun create(
id: Long? = null, id: Long? = null,
orderId: String = ORDER_ID, orderId: String = ORDER_ID,
paymentKey: String = PAYMENT_KEY, paymentKey: String = PAYMENT_KEY,
totalAmount: Long = AMOUNT, totalAmount: Long = AMOUNT,
reservation: ReservationEntity = ReservationFixture.create(id = 1L), reservation: ReservationEntity = ReservationFixture.create(id = 1L),
approvedAt: OffsetDateTime = OffsetDateTime.now() approvedAt: OffsetDateTime = OffsetDateTime.now()
): PaymentEntity = PaymentEntity( ): PaymentEntity = PaymentEntity(
id = id, id = id,
orderId = orderId, orderId = orderId,
paymentKey = paymentKey, paymentKey = paymentKey,
totalAmount = totalAmount, totalAmount = totalAmount,
reservation = reservation, reservation = reservation,
approvedAt = approvedAt approvedAt = approvedAt
) )
fun createCanceled( fun createCanceled(
id: Long? = null, id: Long? = null,
paymentKey: String = PAYMENT_KEY, paymentKey: String = PAYMENT_KEY,
cancelReason: String = "Test Cancel", cancelReason: String = "Test Cancel",
cancelAmount: Long = AMOUNT, cancelAmount: Long = AMOUNT,
approvedAt: OffsetDateTime = OffsetDateTime.now(), approvedAt: OffsetDateTime = OffsetDateTime.now(),
canceledAt: OffsetDateTime = approvedAt.plusHours(1) canceledAt: OffsetDateTime = approvedAt.plusHours(1)
): CanceledPaymentEntity = CanceledPaymentEntity( ): CanceledPaymentEntity = CanceledPaymentEntity(
id = id, id = id,
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = cancelReason, cancelReason = cancelReason,
cancelAmount = cancelAmount, cancelAmount = cancelAmount,
approvedAt = approvedAt, approvedAt = approvedAt,
canceledAt = canceledAt canceledAt = canceledAt
) )
fun createApproveRequest(): PaymentApproveRequest = PaymentApproveRequest( fun createApproveRequest(): PaymentApproveRequest = PaymentApproveRequest(
paymentKey = PAYMENT_KEY, paymentKey = PAYMENT_KEY,
orderId = ORDER_ID, orderId = ORDER_ID,
amount = AMOUNT, amount = AMOUNT,
paymentType = "CARD" paymentType = "CARD"
) )
fun createApproveResponse(): PaymentApproveResponse = PaymentApproveResponse( fun createApproveResponse(): PaymentApproveResponse = PaymentApproveResponse(
paymentKey = PAYMENT_KEY, paymentKey = PAYMENT_KEY,
orderId = ORDER_ID, orderId = ORDER_ID,
approvedAt = OffsetDateTime.now(), approvedAt = OffsetDateTime.now(),
totalAmount = AMOUNT totalAmount = AMOUNT
) )
fun createCancelRequest(): PaymentCancelRequest = PaymentCancelRequest( fun createCancelRequest(): PaymentCancelRequest = PaymentCancelRequest(
paymentKey = PAYMENT_KEY, paymentKey = PAYMENT_KEY,
amount = AMOUNT, amount = AMOUNT,
cancelReason = "Test Cancel" cancelReason = "Test Cancel"
) )
fun createCancelResponse(): PaymentCancelResponse = PaymentCancelResponse( fun createCancelResponse(): PaymentCancelResponse = PaymentCancelResponse(
cancelStatus = "SUCCESS", cancelStatus = "SUCCESS",
cancelReason = "Test Cancel", cancelReason = "Test Cancel",
cancelAmount = AMOUNT, cancelAmount = AMOUNT,
canceledAt = OffsetDateTime.now().plusMinutes(1) canceledAt = OffsetDateTime.now().plusMinutes(1)
) )
} }

View File

@ -1,6 +1,6 @@
spring: spring:
jpa: jpa:
show-sql: true show-sql: false
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
@ -24,5 +24,6 @@ payment:
logging: logging:
level: level:
org.springframework.orm.jpa: DEBUG root: INFO
org.springframework.orm.jpa: INFO
org.springframework.transaction: DEBUG org.springframework.transaction: DEBUG