generated from pricelees/issue-pr-template
Compare commits
13 Commits
refactor/#
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e572c842c | |||
| 7a236a8196 | |||
| 0756e21b63 | |||
| 162e5bbc79 | |||
| be2e6c606e | |||
| 06f7faf7f9 | |||
| 79de5c9c63 | |||
| 5f2e44bb11 | |||
| bba3266f3f | |||
| 135b13a9bf | |||
| 047e4a395b | |||
| 8215492eea | |||
| 186d6e118c |
11
Dockerfile
11
Dockerfile
@ -1,10 +1,9 @@
|
||||
FROM gradle:8-jdk17 AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN ./gradlew bootjar --no-daemon
|
||||
|
||||
FROM amazoncorretto:17
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY service/build/libs/service.jar app.jar
|
||||
|
||||
EXPOSE 8080
|
||||
COPY --from=builder /app/build/libs/*.jar app.jar
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
6
build.sh
Executable file
6
build.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
IMAGE_NAME="roomescape-backend"
|
||||
IMAGE_TAG=$1
|
||||
|
||||
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push
|
||||
@ -7,7 +7,6 @@ import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.equals.shouldBeEqual
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
||||
@ -48,7 +47,10 @@ class AbstractLogMaskingConverterTest : FunSpec({
|
||||
event.formattedMessage
|
||||
} returns json.format(account, address)
|
||||
|
||||
converter.convert(event) shouldBeEqual json.format("${account.first()}${converter.mask}${account.last()}", "${address.first()}${converter.mask}${address.last()}")
|
||||
converter.convert(event) shouldBeEqual json.format(
|
||||
"${account.first()}${converter.mask}${account.last()}",
|
||||
"${address.first()}${converter.mask}${address.last()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedBy
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
|
||||
) : PersistableBaseEntity(id) {
|
||||
@Column(updatable = false)
|
||||
@CreatedDate
|
||||
lateinit var createdAt: LocalDateTime
|
||||
lateinit var createdAt: Instant
|
||||
|
||||
@Column(updatable = false)
|
||||
@CreatedBy
|
||||
@ -25,7 +25,7 @@ abstract class AuditingBaseEntity(
|
||||
|
||||
@Column
|
||||
@LastModifiedDate
|
||||
lateinit var updatedAt: LocalDateTime
|
||||
lateinit var updatedAt: Instant
|
||||
|
||||
@Column
|
||||
@LastModifiedBy
|
||||
|
||||
@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
class TestAuditingBaseEntity(
|
||||
id: Long,
|
||||
val name: String
|
||||
): AuditingBaseEntity(id)
|
||||
) : AuditingBaseEntity(id)
|
||||
|
||||
interface TestAuditingBaseEntityRepository: JpaRepository<TestAuditingBaseEntity, Long>
|
||||
interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>
|
||||
@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
class TestPersistableBaseEntity(
|
||||
id: Long,
|
||||
val name: String
|
||||
): PersistableBaseEntity(id)
|
||||
) : PersistableBaseEntity(id)
|
||||
|
||||
interface TestPersistableBaseEntityRepository: JpaRepository<TestPersistableBaseEntity, Long>
|
||||
interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>
|
||||
@ -5,11 +5,7 @@ import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.equality.shouldBeEqualUsingFields
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import io.mockk.*
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
package com.sangdol.common.utils
|
||||
|
||||
import java.time.*
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
|
||||
|
||||
object KoreaDate {
|
||||
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
|
||||
}
|
||||
|
||||
object KoreaTime {
|
||||
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
|
||||
}
|
||||
|
||||
object KoreaDateTime {
|
||||
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
|
||||
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
|
||||
}
|
||||
|
||||
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()
|
||||
@ -0,0 +1,45 @@
|
||||
package com.sangdol.common.utils
|
||||
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.*
|
||||
|
||||
class KoreaDateTimeExtensionsTest : FunSpec({
|
||||
|
||||
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
|
||||
assertSoftly(KoreaTime.now()) {
|
||||
val utcNow = LocalTime.now(ZoneId.of("UTC"))
|
||||
|
||||
this.hour shouldBe utcNow.hour.plus(9)
|
||||
this.minute shouldBe utcNow.minute
|
||||
this.second shouldBe 0
|
||||
this.nano shouldBe 0
|
||||
}
|
||||
}
|
||||
|
||||
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
|
||||
assertSoftly(KoreaDateTime.now()) {
|
||||
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
|
||||
|
||||
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
|
||||
}
|
||||
}
|
||||
|
||||
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
|
||||
assertSoftly(KoreaDateTime.nowWithOffset()) {
|
||||
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
|
||||
|
||||
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
|
||||
.withSecond(0).withNano(0)
|
||||
}
|
||||
}
|
||||
|
||||
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
|
||||
val now = Instant.now()
|
||||
val kstConverted = now.toKoreaDateTime()
|
||||
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
|
||||
|
||||
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,26 @@
|
||||
package com.sangdol.common.web.asepct
|
||||
|
||||
import io.micrometer.observation.Observation
|
||||
import io.micrometer.observation.ObservationRegistry
|
||||
import org.aspectj.lang.ProceedingJoinPoint
|
||||
import org.aspectj.lang.annotation.Around
|
||||
import org.aspectj.lang.annotation.Aspect
|
||||
import org.aspectj.lang.annotation.Pointcut
|
||||
|
||||
@Aspect
|
||||
class ServiceObservationAspect(
|
||||
private val observationRegistry: ObservationRegistry
|
||||
) {
|
||||
|
||||
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
|
||||
fun allServices() {
|
||||
}
|
||||
|
||||
@Around("allServices()")
|
||||
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
|
||||
val methodName: String = joinPoint.signature.toShortString()
|
||||
|
||||
return Observation.createNotStarted(methodName, observationRegistry)
|
||||
.observe<Any?> { joinPoint.proceed() }
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
package com.sangdol.common.web.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonSerializer
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
|
||||
@ -15,19 +12,13 @@ import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Configuration
|
||||
class JacksonConfig {
|
||||
|
||||
companion object {
|
||||
private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
|
||||
|
||||
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
}
|
||||
@ -35,9 +26,9 @@ class JacksonConfig {
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = ObjectMapper()
|
||||
.registerModule(javaTimeModule())
|
||||
.registerModule(dateTimeModule())
|
||||
.registerModule(kotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||
|
||||
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
|
||||
.addSerializer(
|
||||
@ -56,35 +47,4 @@ class JacksonConfig {
|
||||
LocalTime::class.java,
|
||||
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
|
||||
) as JavaTimeModule
|
||||
|
||||
private fun dateTimeModule(): SimpleModule {
|
||||
val simpleModule = SimpleModule()
|
||||
simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer())
|
||||
simpleModule.addSerializer(OffsetDateTime::class.java, OffsetDateTimeSerializer())
|
||||
return simpleModule
|
||||
}
|
||||
|
||||
class LocalDateTimeSerializer : JsonSerializer<LocalDateTime>() {
|
||||
override fun serialize(
|
||||
value: LocalDateTime,
|
||||
gen: JsonGenerator,
|
||||
serializers: SerializerProvider
|
||||
) {
|
||||
value.atZone(ZoneId.systemDefault())
|
||||
.toOffsetDateTime()
|
||||
.also {
|
||||
gen.writeString(it.format(ISO_OFFSET_DATE_TIME_FORMATTER))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OffsetDateTimeSerializer : JsonSerializer<OffsetDateTime>() {
|
||||
override fun serialize(
|
||||
value: OffsetDateTime,
|
||||
gen: JsonGenerator,
|
||||
serializers: SerializerProvider
|
||||
) {
|
||||
gen.writeString(value.format(ISO_OFFSET_DATE_TIME_FORMATTER))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
package com.sangdol.common.web.config
|
||||
|
||||
import com.sangdol.common.web.asepct.ServiceObservationAspect
|
||||
import io.micrometer.observation.ObservationPredicate
|
||||
import io.micrometer.observation.ObservationRegistry
|
||||
import io.micrometer.observation.aop.ObservedAspect
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.http.server.observation.ServerRequestObservationContext
|
||||
|
||||
@Configuration
|
||||
class ObservationConfig(
|
||||
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
|
||||
return ObservedAspect(observationRegistry)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
|
||||
return ServiceObservationAspect(observationRegistry)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun excludeActuatorPredicate(): ObservationPredicate {
|
||||
return ObservationPredicate { _, context ->
|
||||
if (context !is ServerRequestObservationContext) {
|
||||
return@ObservationPredicate true
|
||||
}
|
||||
|
||||
val servletRequest: HttpServletRequest = context.carrier
|
||||
val requestUri = servletRequest.requestURI
|
||||
|
||||
!requestUri.contains(actuatorPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
||||
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||
import io.micrometer.tracing.CurrentTraceContext
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@ -17,9 +18,10 @@ class WebLoggingConfig {
|
||||
@Bean
|
||||
@DependsOn(value = ["webLogMessageConverter"])
|
||||
fun filterRegistrationBean(
|
||||
webLogMessageConverter: WebLogMessageConverter
|
||||
webLogMessageConverter: WebLogMessageConverter,
|
||||
currentTraceContext: CurrentTraceContext
|
||||
): FilterRegistrationBean<OncePerRequestFilter> {
|
||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
|
||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
|
||||
|
||||
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
||||
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package com.sangdol.common.web.exception
|
||||
|
||||
import com.sangdol.common.log.constant.LogType
|
||||
import com.sangdol.common.types.exception.CommonErrorCode
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
@ -31,7 +30,7 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
@ -57,7 +56,7 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
@ -75,30 +74,26 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
.body(errorResponse)
|
||||
}
|
||||
|
||||
private fun logException(
|
||||
private fun convertExceptionLogMessage(
|
||||
servletRequest: HttpServletRequest,
|
||||
httpStatus: HttpStatus,
|
||||
errorResponse: CommonErrorResponse,
|
||||
exception: Exception
|
||||
) {
|
||||
val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
|
||||
): String {
|
||||
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
|
||||
|
||||
val logMessage = messageConverter.convertToResponseMessage(
|
||||
type = type,
|
||||
return messageConverter.convertToErrorResponseMessage(
|
||||
servletRequest = servletRequest,
|
||||
httpStatusCode = httpStatus.value(),
|
||||
httpStatus = httpStatus,
|
||||
responseBody = errorResponse,
|
||||
exception = actualException
|
||||
)
|
||||
|
||||
log.warn { logMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import com.sangdol.common.utils.MdcStartTimeUtil
|
||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.micrometer.tracing.CurrentTraceContext
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
@ -15,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
class HttpRequestLoggingFilter(
|
||||
private val messageConverter: WebLogMessageConverter
|
||||
private val messageConverter: WebLogMessageConverter,
|
||||
private val currentTraceContext: CurrentTraceContext
|
||||
) : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
@ -32,9 +34,12 @@ class HttpRequestLoggingFilter(
|
||||
try {
|
||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||
cachedResponse.copyBodyToResponse()
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
} finally {
|
||||
MdcStartTimeUtil.clear()
|
||||
MdcPrincipalIdUtil.clear()
|
||||
currentTraceContext.maybeScope(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sangdol.common.log.constant.LogType
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
|
||||
class WebLogMessageConverter(
|
||||
@ -18,7 +19,10 @@ class WebLogMessageConverter(
|
||||
return objectMapper.writeValueAsString(payload)
|
||||
}
|
||||
|
||||
fun convertToControllerInvokedMessage(servletRequest: HttpServletRequest, controllerPayload: Map<String, Any>): String {
|
||||
fun convertToControllerInvokedMessage(
|
||||
servletRequest: HttpServletRequest,
|
||||
controllerPayload: Map<String, Any>
|
||||
): String {
|
||||
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
|
||||
.endpoint()
|
||||
.principalId()
|
||||
@ -46,4 +50,19 @@ class WebLogMessageConverter(
|
||||
|
||||
return objectMapper.writeValueAsString(payload)
|
||||
}
|
||||
|
||||
fun convertToErrorResponseMessage(
|
||||
servletRequest: HttpServletRequest,
|
||||
httpStatus: HttpStatus,
|
||||
responseBody: Any? = null,
|
||||
exception: Exception? = null,
|
||||
): String {
|
||||
val type = if (httpStatus.isClientError()) {
|
||||
LogType.APPLICATION_FAILURE
|
||||
} else {
|
||||
LogType.UNHANDLED_EXCEPTION
|
||||
}
|
||||
|
||||
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,7 @@ import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class JacksonConfigTest : FunSpec({
|
||||
|
||||
@ -55,38 +52,4 @@ class JacksonConfigTest : FunSpec({
|
||||
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
|
||||
}
|
||||
}
|
||||
|
||||
context("Long 타입은 문자열로 (역)직렬화된다.") {
|
||||
val number = 1234567890L
|
||||
val serialized: String = objectMapper.writeValueAsString(number)
|
||||
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
|
||||
|
||||
test("Long 직렬화") {
|
||||
serialized shouldBe "$number"
|
||||
}
|
||||
|
||||
test("Long 역직렬화") {
|
||||
deserialized shouldBe number
|
||||
}
|
||||
}
|
||||
|
||||
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
|
||||
val date = LocalDate.of(2025, 7, 14)
|
||||
val time = LocalTime.of(12, 30, 0)
|
||||
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
|
||||
val serialized: String = objectMapper.writeValueAsString(dateTime)
|
||||
|
||||
test("OffsetDateTime 직렬화") {
|
||||
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
|
||||
}
|
||||
}
|
||||
|
||||
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
|
||||
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
|
||||
val serialized: String = objectMapper.writeValueAsString(dateTime)
|
||||
|
||||
test("LocalDateTime 직렬화") {
|
||||
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,7 +23,7 @@ class LogPayloadBuilderTest : FunSpec({
|
||||
beforeTest {
|
||||
method = "GET".also { every { servletRequest.method } returns it }
|
||||
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
|
||||
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
|
||||
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
|
||||
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
|
||||
queryString = "key=value".also { every { servletRequest.queryString } returns it }
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ class WebLogMessageConverterTest : FunSpec({
|
||||
beforeTest {
|
||||
method = "GET".also { every { servletRequest.method } returns it }
|
||||
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
|
||||
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
|
||||
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
|
||||
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
|
||||
queryString = "key=value".also { every { servletRequest.queryString } returns it }
|
||||
}
|
||||
@ -121,7 +121,10 @@ class WebLogMessageConverterTest : FunSpec({
|
||||
this["duration_ms"].shouldNotBeNull()
|
||||
this["principal_id"] shouldBe principalId
|
||||
this["response_body"] shouldBe null
|
||||
this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message)
|
||||
this["exception"] shouldBe mapOf(
|
||||
"class" to exception.javaClass.simpleName,
|
||||
"message" to exception.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,7 +144,10 @@ class WebLogMessageConverterTest : FunSpec({
|
||||
this["duration_ms"].shouldNotBeNull()
|
||||
this["principal_id"] shouldBe principalId
|
||||
this["response_body"] shouldBe body
|
||||
this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message)
|
||||
this["exception"] shouldBe mapOf(
|
||||
"class" to exception.javaClass.simpleName,
|
||||
"message" to exception.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,5 +168,27 @@ class WebLogMessageConverterTest : FunSpec({
|
||||
this["exception"] shouldBe null
|
||||
}
|
||||
}
|
||||
|
||||
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
|
||||
val result = converter.convertToErrorResponseMessage(
|
||||
servletRequest = servletRequest,
|
||||
httpStatus = HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
|
||||
}
|
||||
}
|
||||
|
||||
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
|
||||
val result = converter.convertToErrorResponseMessage(
|
||||
servletRequest = servletRequest,
|
||||
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
14
docker/docker-compose.yaml
Normal file
14
docker/docker-compose.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
services:
|
||||
mysql-local:
|
||||
image: mysql:8.4
|
||||
container_name: mysql-local
|
||||
restart: always
|
||||
ports:
|
||||
- "23306:3306"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: init
|
||||
MYSQL_DATABASE: roomescape_local
|
||||
TZ: UTC
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
@ -1,6 +1,18 @@
|
||||
node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
.gitignore
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Build output
|
||||
build
|
||||
dist
|
||||
build
|
||||
|
||||
# Editor/OS specific
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env*
|
||||
@ -1,18 +1,17 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:24 AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY package-lock.json ./
|
||||
|
||||
RUN npm install --frozen-lockfile
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:latest
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/src/api/order/orderAPI.ts
Normal file
12
frontend/src/api/order/orderAPI.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
|
||||
|
||||
export const confirm = async (
|
||||
reservationId: string,
|
||||
data: PaymentConfirmRequest,
|
||||
): Promise<void> => {
|
||||
return await apiClient.post<void>(
|
||||
`/orders/${reservationId}/confirm`,
|
||||
data
|
||||
);
|
||||
};
|
||||
5
frontend/src/api/order/orderTypes.ts
Normal file
5
frontend/src/api/order/orderTypes.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface OrderErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
trial: number;
|
||||
}
|
||||
@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
|
||||
paymentKey: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
}
|
||||
|
||||
export interface PaymentCancelRequest {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
||||
|
||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||
|
||||
export const ScheduleStatus = {
|
||||
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
|
||||
}
|
||||
|
||||
// Public
|
||||
export interface ScheduleResponse {
|
||||
id: string;
|
||||
date: string;
|
||||
startFrom: string;
|
||||
endAt: string;
|
||||
status: ScheduleStatus;
|
||||
}
|
||||
|
||||
export interface ScheduleThemeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ScheduleStoreInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ScheduleWithStoreAndThemeResponse {
|
||||
schedule: ScheduleResponse,
|
||||
theme: ScheduleThemeInfo,
|
||||
store: ScheduleStoreInfo,
|
||||
}
|
||||
|
||||
export interface ScheduleWithThemeResponse {
|
||||
id: string,
|
||||
startFrom: string,
|
||||
endAt: string,
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
themeDifficulty: Difficulty,
|
||||
status: ScheduleStatus
|
||||
schedule: ScheduleResponse,
|
||||
theme: ScheduleThemeInfo
|
||||
}
|
||||
|
||||
export interface ScheduleWithThemeListResponse {
|
||||
schedules: ScheduleWithThemeResponse[];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Navigate, useLocation} from 'react-router-dom';
|
||||
import {useAuth} from '../context/AuthContext';
|
||||
|
||||
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const { loggedIn, role, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>; // Or a proper spinner component
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
// Not logged in, redirect to login page. No alert needed here
|
||||
// as the user is simply redirected.
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (role !== 'ADMIN') {
|
||||
// Logged in but not an admin, show alert and redirect.
|
||||
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
||||
@ -1,17 +1,17 @@
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||||
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||||
import {getStores} from '@_api/store/storeAPI';
|
||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
||||
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { getStores } from '@_api/store/storeAPI';
|
||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
||||
import { fetchThemeById } from '@_api/theme/themeAPI';
|
||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||||
import {formatDate} from 'src/util/DateTimeFormatter';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
||||
fetchSchedules(selectedStore.id, dateStr)
|
||||
.then(res => {
|
||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||
const key = schedule.themeName;
|
||||
const key = schedule.theme.name;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(schedule);
|
||||
return acc;
|
||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
||||
const handleConfirmReservation = () => {
|
||||
if (!selectedSchedule) return;
|
||||
|
||||
holdSchedule(selectedSchedule.id)
|
||||
holdSchedule(selectedSchedule.schedule.id)
|
||||
.then(() => {
|
||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||||
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||
const reservationData: ReservationData = {
|
||||
scheduleId: selectedSchedule.id,
|
||||
scheduleId: selectedSchedule.schedule.id,
|
||||
store: {
|
||||
id: selectedStore!.id,
|
||||
name: selectedStore!.name,
|
||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
maxParticipants: res.maxParticipants,
|
||||
},
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
startFrom: selectedSchedule.startFrom,
|
||||
endAt: selectedSchedule.endAt,
|
||||
startFrom: selectedSchedule.schedule.startFrom,
|
||||
endAt: selectedSchedule.schedule.endAt,
|
||||
};
|
||||
navigate('/reservation/form', {state: reservationData});
|
||||
}).catch(handleError);
|
||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<h3>3. 시간 선택</h3>
|
||||
<div className="schedule-list">
|
||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
||||
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
||||
<div key={themeName} className="theme-schedule-group">
|
||||
<div className="theme-header">
|
||||
<h4>{themeName}</h4>
|
||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||||
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||
className="theme-detail-button">상세보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="time-slots">
|
||||
{schedules.map(schedule => (
|
||||
{scheduleAndTheme.map(schedule => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
key={schedule.schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
>
|
||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<div className="modal-section modal-info-grid">
|
||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
||||
import { confirm } from '@_api/order/orderAPI';
|
||||
import type { OrderErrorResponse } from '@_api/order/orderTypes';
|
||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import type { AxiosError } from 'axios';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
|
||||
|
||||
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
||||
|
||||
const handleError = (err: any) => {
|
||||
if (isLoginRequiredError(err)) {
|
||||
alert('로그인이 필요해요.');
|
||||
navigate('/login', { state: { from: location } });
|
||||
} else {
|
||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||
alert(message);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!reservationId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: totalPrice,
|
||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||
};
|
||||
|
||||
confirmPayment(reservationId, paymentData)
|
||||
.then(() => {
|
||||
return confirmReservation(reservationId);
|
||||
})
|
||||
confirm(reservationId, paymentData)
|
||||
.then(() => {
|
||||
alert('결제가 완료되었어요!');
|
||||
navigate('/reservation/success', {
|
||||
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(handleError);
|
||||
.catch(err => {
|
||||
const error = err as AxiosError<OrderErrorResponse>;
|
||||
const errorCode = error.response?.data?.code;
|
||||
const errorMessage = error.response?.data?.message;
|
||||
|
||||
if (errorCode === 'B000') {
|
||||
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
|
||||
navigate('/reservation');
|
||||
return;
|
||||
}
|
||||
|
||||
const trial = error.response?.data?.trial || 0;
|
||||
if (trial < 2) {
|
||||
alert(errorMessage);
|
||||
return;
|
||||
}
|
||||
alert(errorMessage);
|
||||
|
||||
setTimeout(() => {
|
||||
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
|
||||
|
||||
if (agreeToOnsitePayment) {
|
||||
confirmReservation(reservationId)
|
||||
.then(() => {
|
||||
navigate('/reservation/success', {
|
||||
state: {
|
||||
storeName,
|
||||
themeName,
|
||||
date,
|
||||
time,
|
||||
participantCount,
|
||||
totalPrice,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
|
||||
navigate('/');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}).catch((error: any) => {
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -10,10 +10,11 @@ import {
|
||||
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
||||
import {getStores} from '@_api/store/storeAPI';
|
||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||
import {fetchActiveThemes, fetchThemeById} from '@_api/theme/themeAPI';
|
||||
import {fetchActiveThemes} from '@_api/theme/themeAPI';
|
||||
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
||||
import '@_css/admin-schedule-page.css';
|
||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
||||
import React, {Fragment, useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
|
||||
@ -53,8 +54,8 @@ const AdminSchedulePage: React.FC = () => {
|
||||
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false);
|
||||
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||
const [isLoadingThemeDetails] = useState<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -332,10 +333,10 @@ const AdminSchedulePage: React.FC = () => {
|
||||
<h4 className="audit-title">감사 정보</h4>
|
||||
<div className="audit-body">
|
||||
<p>
|
||||
<strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
|
||||
<strong>생성일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
|
||||
<strong>수정일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||
import type { SidoResponse, SigunguResponse } from '@_api/region/regionTypes';
|
||||
import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
|
||||
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
|
||||
import {
|
||||
type SimpleStoreResponse,
|
||||
type StoreDetailResponse,
|
||||
type StoreRegisterRequest,
|
||||
type UpdateStoreRequest
|
||||
} from '@_api/store/storeTypes';
|
||||
import { useAdminAuth } from '@_context/AdminAuthContext';
|
||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
||||
import '@_css/admin-store-page.css';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
||||
import React, {Fragment, useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
|
||||
const AdminStorePage: React.FC = () => {
|
||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
||||
@ -297,10 +298,10 @@ const AdminStorePage: React.FC = () => {
|
||||
코드:</strong> {detailedStores[store.id].region.code}
|
||||
</p>
|
||||
<p>
|
||||
<strong>생성일:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
|
||||
<strong>생성일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>수정일:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
|
||||
<strong>수정일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
||||
|
||||
@ -9,7 +9,8 @@ import {
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
||||
import '@_css/admin-theme-edit-page.css';
|
||||
import type { AuditInfo } from '@_api/common/commonTypes';
|
||||
import type {AuditInfo} from '@_api/common/commonTypes';
|
||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
||||
|
||||
interface ThemeFormData {
|
||||
name: string;
|
||||
@ -256,8 +257,8 @@ const AdminThemeEditPage: React.FC = () => {
|
||||
<div className="audit-info">
|
||||
<h4 className="audit-title">감사 정보</h4>
|
||||
<div className="audit-body">
|
||||
<p><strong>생성일:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
|
||||
<p><strong>수정일:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
|
||||
<p><strong>생성일:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
|
||||
<p><strong>수정일:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
|
||||
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
||||
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
||||
</div>
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
|
||||
845
query.md
845
query.md
@ -1,845 +0,0 @@
|
||||
## Auth
|
||||
|
||||
**로그인**
|
||||
|
||||
```sql
|
||||
-- 회원
|
||||
|
||||
-- 이메일로 회원 조회
|
||||
SELECT
|
||||
u.id
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.email = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- 연락처로 회원 조회
|
||||
SELECT
|
||||
u.id
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.phone = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- 회원 추가
|
||||
INSERT INTO users (
|
||||
created_at, created_by, email, name, password, phone, region_code,
|
||||
status, updated_at, updated_by, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
|
||||
-- 회원 상태 이력 추가
|
||||
INSERT INTO user_status_history (
|
||||
created_at, created_by, reason, status, updated_at, updated_by,
|
||||
user_id, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
### Payment
|
||||
|
||||
**결제 승인 & 저장**
|
||||
|
||||
```sql
|
||||
-- 결제 정보 추가
|
||||
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
|
||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
|
||||
-- 결제 상세 정보 추가
|
||||
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
|
||||
) VALUES ( ?, ?, ?, ?
|
||||
);
|
||||
-- 카드 결제 상세 정보 추가
|
||||
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
|
||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**결제 취소**
|
||||
|
||||
SQL
|
||||
|
||||
```sql
|
||||
-- 예약 ID로 결제 정보 조회
|
||||
SELECT
|
||||
p.id,
|
||||
p.approved_at,
|
||||
p.method,
|
||||
p.order_id,
|
||||
p.payment_key,
|
||||
p.requested_at,
|
||||
p.reservation_id,
|
||||
p.status,
|
||||
p.total_amount,
|
||||
p.type
|
||||
FROM
|
||||
payment p
|
||||
WHERE
|
||||
p.reservation_id = ?;
|
||||
|
||||
-- 추가
|
||||
-- 취소된 결제 정보 추가
|
||||
INSERT INTO canceled_payment (
|
||||
cancel_amount, cancel_reason, canceled_at, canceled_by,
|
||||
card_discount_amount, easypay_discount_amount, payment_id,
|
||||
requested_at, transfer_discount_amount, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
### Region
|
||||
|
||||
**모든 시/도 조회**
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT
|
||||
r.sido_code,
|
||||
r.sido_name
|
||||
FROM
|
||||
region r
|
||||
ORDER BY
|
||||
r.sido_name;
|
||||
```
|
||||
|
||||
**시/군/구 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
r.sigungu_code,
|
||||
r.sigungu_name
|
||||
FROM
|
||||
region r
|
||||
WHERE
|
||||
r.sido_code = ?
|
||||
GROUP BY
|
||||
r.sigungu_code, r.sigungu_name
|
||||
ORDER BY
|
||||
r.sigungu_name;
|
||||
```
|
||||
|
||||
**지역 코드 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
r.code
|
||||
FROM
|
||||
region r
|
||||
WHERE
|
||||
r.sido_code = ? AND r.sigungu_code = ?;
|
||||
```
|
||||
|
||||
### Reservation
|
||||
|
||||
**Pending 예약 생성**
|
||||
|
||||
```sql
|
||||
-- schedule 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- theme 조회
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.id = ?;
|
||||
|
||||
-- 예약 추가
|
||||
INSERT INTO reservation (
|
||||
created_at, created_by, participant_count, requirement,
|
||||
reserver_contact, reserver_name, schedule_id, status,
|
||||
updated_at, updated_by, user_id, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**확정**
|
||||
|
||||
```sql
|
||||
-- 예약 조회
|
||||
SELECT
|
||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
||||
r.updated_at, r.updated_by, r.user_id
|
||||
FROM
|
||||
reservation r
|
||||
WHERE
|
||||
r.id = ?;
|
||||
|
||||
-- 일정 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 예약 확정
|
||||
UPDATE
|
||||
reservation
|
||||
SET
|
||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
||||
reserver_name = ?, schedule_id = ?, status = ?,
|
||||
updated_at = ?, updated_by = ?, user_id = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
|
||||
-- Schedule 확정
|
||||
UPDATE
|
||||
schedule
|
||||
SET
|
||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**취소**
|
||||
|
||||
```sql
|
||||
-- 예약 조회
|
||||
SELECT
|
||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
||||
r.updated_at, r.updated_by, r.user_id
|
||||
FROM
|
||||
reservation r
|
||||
WHERE
|
||||
r.id = ?;
|
||||
|
||||
-- 일정 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 취소 예약 추가
|
||||
INSERT INTO canceled_reservation (
|
||||
cancel_reason, canceled_at, canceled_by,
|
||||
reservation_id, status, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?
|
||||
);
|
||||
|
||||
-- 예약 취소
|
||||
UPDATE
|
||||
reservation
|
||||
SET
|
||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
||||
reserver_name = ?, schedule_id = ?, status = ?,
|
||||
updated_at = ?, updated_by = ?, user_id = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
|
||||
-- 일정 활성화
|
||||
UPDATE
|
||||
schedule
|
||||
SET
|
||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**회원 예약 조회**
|
||||
|
||||
```sql
|
||||
-- 예약 조회
|
||||
SELECT
|
||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
||||
r.updated_at, r.updated_by, r.user_id
|
||||
FROM
|
||||
reservation r
|
||||
WHERE
|
||||
r.user_id = ? AND r.status IN (?, ?);
|
||||
|
||||
-- 일정 조회 -> 각 예약별 1개씩(N개)
|
||||
SELECT
|
||||
s.id,
|
||||
st.id AS store_id,
|
||||
st.name AS store_name,
|
||||
s.date,
|
||||
s.time,
|
||||
t.id AS theme_id,
|
||||
t.name AS theme_name,
|
||||
t.difficulty,
|
||||
t.available_minutes,
|
||||
s.status
|
||||
FROM
|
||||
schedule s
|
||||
JOIN theme t ON t.id = s.theme_id
|
||||
JOIN store st ON st.id = s.store_id
|
||||
WHERE
|
||||
s.id = ?;
|
||||
```
|
||||
|
||||
**예약 상세 조회**
|
||||
|
||||
```sql
|
||||
-- 예약 조회
|
||||
SELECT
|
||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
||||
r.updated_at, r.updated_by, r.user_id
|
||||
FROM
|
||||
reservation r
|
||||
WHERE
|
||||
r.id = ?;
|
||||
|
||||
-- 회원 연락처 정보 조회
|
||||
SELECT
|
||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.id = ?;
|
||||
|
||||
-- 결제 정보 조회
|
||||
SELECT
|
||||
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
|
||||
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
|
||||
FROM
|
||||
payment p
|
||||
WHERE
|
||||
p.reservation_id = ?;
|
||||
|
||||
-- 결제 상세 정보 조회
|
||||
SELECT
|
||||
pd.id,
|
||||
CASE
|
||||
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
|
||||
WHEN pcd.id IS NOT NULL THEN 2 -- card
|
||||
WHEN pep.id IS NOT NULL THEN 3 -- easypay
|
||||
WHEN pd.id IS NOT NULL THEN 0 -- etc
|
||||
END AS payment_type,
|
||||
pd.payment_id, pd.supplied_amount, pd.vat,
|
||||
pbt.bank_code, pbt.settlement_status,
|
||||
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
|
||||
pcd.easypay_discount_amount, pcd.easypay_provider_code,
|
||||
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
|
||||
pcd.owner_type,
|
||||
pep.amount AS easypay_amount,
|
||||
pep.discount_amount AS easypay_discount_amount,
|
||||
pep.easypay_provider_code AS easypay_provider
|
||||
FROM
|
||||
payment_detail pd
|
||||
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
|
||||
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
|
||||
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
|
||||
WHERE
|
||||
pd.payment_id = ?;
|
||||
|
||||
-- 취소 결제 정보 조회
|
||||
SELECT
|
||||
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
|
||||
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
|
||||
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
|
||||
FROM
|
||||
canceled_payment cp
|
||||
WHERE
|
||||
cp.payment_id = ?;
|
||||
```
|
||||
|
||||
### Schedule
|
||||
|
||||
**날짜, 시간, 테마로 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.id,
|
||||
st.id AS store_id,
|
||||
st.name AS store_name,
|
||||
s.date,
|
||||
s.time,
|
||||
t.id AS theme_id,
|
||||
t.name AS theme_name,
|
||||
t.difficulty,
|
||||
t.available_minutes,
|
||||
s.status
|
||||
FROM
|
||||
schedule s
|
||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
|
||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
||||
WHERE
|
||||
s.date = ?
|
||||
```
|
||||
|
||||
**감사 정보 조회**
|
||||
|
||||
```sql
|
||||
-- 일정 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 작업자 조회(createdBy, updatedBy)
|
||||
SELECT
|
||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
||||
a.updated_by
|
||||
FROM
|
||||
admin a
|
||||
WHERE
|
||||
a.id = ?;
|
||||
```
|
||||
|
||||
**일정 생성**
|
||||
|
||||
```sql
|
||||
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM schedule s
|
||||
WHERE
|
||||
s.store_id = ?
|
||||
AND s.date = ?
|
||||
AND s.theme_id = ?
|
||||
AND s.time = ?
|
||||
);
|
||||
|
||||
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
|
||||
SELECT
|
||||
s.id,
|
||||
st.id AS store_id,
|
||||
st.name AS store_name,
|
||||
s.date,
|
||||
s.time,
|
||||
t.id AS theme_id,
|
||||
t.name AS theme_name,
|
||||
t.difficulty,
|
||||
t.available_minutes,
|
||||
s.status
|
||||
FROM
|
||||
schedule s
|
||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
|
||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
||||
WHERE
|
||||
s.date = ?
|
||||
|
||||
-- 일정 추가
|
||||
INSERT INTO schedule (
|
||||
created_at, created_by, date, status, store_id,
|
||||
theme_id, time, updated_at, updated_by, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**일정 수정**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 수정
|
||||
UPDATE
|
||||
schedule
|
||||
SET
|
||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**일정 삭제**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 삭제
|
||||
DELETE FROM schedule
|
||||
WHERE id = ?;
|
||||
```
|
||||
|
||||
**상태 → HOLD 변경**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
||||
FROM
|
||||
schedule s
|
||||
WHERE
|
||||
s.id = ?;
|
||||
|
||||
-- 수정
|
||||
UPDATE
|
||||
schedule
|
||||
SET
|
||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
### Store
|
||||
|
||||
**매장 상세 조회**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
||||
s.updated_by
|
||||
FROM
|
||||
store s
|
||||
WHERE
|
||||
s.id = ? AND s.status = 'ACTIVE';
|
||||
|
||||
-- 지역 정보 조회
|
||||
SELECT
|
||||
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
|
||||
FROM
|
||||
region r
|
||||
WHERE
|
||||
r.code = ?;
|
||||
|
||||
-- 감사 정보 조회(createdBy, updatedBy)
|
||||
SELECT
|
||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
||||
a.updated_by
|
||||
FROM
|
||||
admin a
|
||||
WHERE
|
||||
a.id = ?;
|
||||
```
|
||||
|
||||
**매장 등록**
|
||||
|
||||
```sql
|
||||
-- 이름 중복 확인
|
||||
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
|
||||
|
||||
-- 연락처 중복 확인
|
||||
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
|
||||
|
||||
-- 주소 중복 확인
|
||||
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
|
||||
|
||||
-- 사업자번호 중복 확인
|
||||
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
|
||||
|
||||
-- 추가
|
||||
INSERT INTO store (
|
||||
address, business_reg_num, contact, created_at, created_by,
|
||||
name, region_code, status, updated_at, updated_by, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**매장 수정**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
||||
s.updated_by
|
||||
FROM
|
||||
store s
|
||||
WHERE
|
||||
s.id = ? AND s.status = 'ACTIVE';
|
||||
|
||||
-- 수정
|
||||
UPDATE
|
||||
store
|
||||
SET
|
||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**비활성화(status = DISABLE)**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
||||
s.updated_by
|
||||
FROM
|
||||
store s
|
||||
WHERE
|
||||
s.id = ? AND s.status = 'ACTIVE';
|
||||
|
||||
-- 수정
|
||||
UPDATE
|
||||
store
|
||||
SET
|
||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**모든 매장 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
||||
s.updated_by
|
||||
FROM
|
||||
store s
|
||||
WHERE
|
||||
s.status = 'ACTIVE'
|
||||
AND (? IS NULL OR s.region_code LIKE ?);
|
||||
```
|
||||
|
||||
**개별 매장 상세 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
||||
s.updated_by
|
||||
FROM
|
||||
store s
|
||||
WHERE
|
||||
s.id = ? AND s.status = 'ACTIVE';
|
||||
```
|
||||
|
||||
### Theme
|
||||
|
||||
**생성**
|
||||
|
||||
```sql
|
||||
-- 이름으로 조회
|
||||
SELECT
|
||||
t.id
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.name = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- 추가
|
||||
INSERT INTO theme (
|
||||
available_minutes, created_at, created_by, description, difficulty,
|
||||
expected_minutes_from, expected_minutes_to, is_active, max_participants,
|
||||
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**Active인 모든 테마 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.is_active = TRUE;
|
||||
```
|
||||
|
||||
**테마 목록 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t;
|
||||
```
|
||||
|
||||
**감사 정보 포함 개별 테마 상세 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.id = ?;
|
||||
```
|
||||
|
||||
**개별 테마 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.id = ?;
|
||||
```
|
||||
|
||||
**삭제**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.id = ?;
|
||||
|
||||
-- 삭제
|
||||
DELETE FROM theme WHERE id = ?;
|
||||
```
|
||||
|
||||
**수정**
|
||||
|
||||
```sql
|
||||
-- 조회
|
||||
SELECT
|
||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
||||
FROM
|
||||
theme t
|
||||
WHERE
|
||||
t.id = ?;
|
||||
|
||||
-- 수정
|
||||
UPDATE
|
||||
theme
|
||||
SET
|
||||
available_minutes = ?, description = ?, difficulty = ?,
|
||||
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
|
||||
max_participants = ?, min_participants = ?, name = ?, price = ?,
|
||||
thumbnail_url = ?, updated_at = ?, updated_by = ?
|
||||
WHERE
|
||||
id = ?;
|
||||
```
|
||||
|
||||
**인기 테마 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
|
||||
t.min_participants, t.max_participants,
|
||||
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
|
||||
FROM
|
||||
theme t
|
||||
JOIN (
|
||||
SELECT
|
||||
s.theme_id, count(*) as reservation_count
|
||||
FROM
|
||||
schedule s
|
||||
JOIN
|
||||
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
|
||||
WHERE
|
||||
s.status = 'RESERVED'
|
||||
AND (s.date BETWEEN :startFrom AND :endAt)
|
||||
GROUP BY
|
||||
s.theme_id
|
||||
ORDER BY
|
||||
reservation_count desc
|
||||
LIMIT :count
|
||||
) ranked_themes ON t.id = ranked_themes.theme_id
|
||||
```
|
||||
|
||||
### User
|
||||
|
||||
**회원가입**
|
||||
|
||||
```sql
|
||||
-- 이메일 중복 확인
|
||||
SELECT
|
||||
u.id
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.email = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- 연락처 중복 확인
|
||||
SELECT
|
||||
u.id
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.phone = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- 추가
|
||||
INSERT INTO users (
|
||||
created_at, created_by, email, name, password, phone, region_code,
|
||||
status, updated_at, updated_by, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
|
||||
-- 상태 변경 이력 추가
|
||||
INSERT INTO user_status_history (
|
||||
created_at, created_by, reason, status, updated_at, updated_by,
|
||||
user_id, id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?
|
||||
);
|
||||
```
|
||||
|
||||
**연락처 정보 조회**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
||||
FROM
|
||||
users u
|
||||
WHERE
|
||||
u.id = ?;
|
||||
```
|
||||
@ -8,6 +8,10 @@ dependencies {
|
||||
// API docs
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
||||
|
||||
// Cache
|
||||
implementation("org.springframework.boot:spring-boot-starter-cache")
|
||||
implementation("com.github.ben-manes.caffeine:caffeine")
|
||||
|
||||
// DB
|
||||
runtimeOnly("com.h2database:h2")
|
||||
runtimeOnly("com.mysql:mysql-connector-j")
|
||||
|
||||
@ -3,13 +3,23 @@ package com.sangdol.roomescape
|
||||
import org.springframework.boot.Banner
|
||||
import org.springframework.boot.SpringApplication
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.cache.annotation.EnableCaching
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import java.util.*
|
||||
|
||||
@EnableAsync
|
||||
@EnableCaching
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(
|
||||
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
|
||||
)
|
||||
class RoomescapeApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
System.setProperty("user.timezone", "UTC")
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
|
||||
val springApplication = SpringApplication(RoomescapeApplication::class.java)
|
||||
springApplication.setBannerMode(Banner.Mode.OFF)
|
||||
springApplication.run()
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package com.sangdol.roomescape.admin.business
|
||||
|
||||
import com.sangdol.roomescape.common.types.Auditor
|
||||
import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials
|
||||
import com.sangdol.roomescape.admin.business.dto.toCredentials
|
||||
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||
import com.sangdol.roomescape.admin.mapper.toCredentials
|
||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||
import com.sangdol.roomescape.admin.exception.AdminException
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
||||
import com.sangdol.roomescape.common.types.Auditor
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@ -20,29 +20,29 @@ class AdminService(
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
||||
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
||||
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
||||
|
||||
return adminRepository.findByAccount(account)
|
||||
?.let {
|
||||
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
|
||||
log.info { "[findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
|
||||
it.toCredentials()
|
||||
}
|
||||
?: run {
|
||||
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
||||
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
||||
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findOperatorOrUnknown(id: Long): Auditor {
|
||||
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
||||
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
||||
|
||||
return adminRepository.findByIdOrNull(id)?.let { admin ->
|
||||
Auditor(admin.id, admin.name).also {
|
||||
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
|
||||
log.info { "[findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
|
||||
}
|
||||
} ?: run {
|
||||
log.warn { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" }
|
||||
log.warn { "[findOperatorById] 작업자 정보 조회 실패. id=${id}" }
|
||||
Auditor.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
package com.sangdol.roomescape.admin.business.dto
|
||||
package com.sangdol.roomescape.admin.dto
|
||||
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||
import com.sangdol.roomescape.auth.web.LoginCredentials
|
||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
|
||||
data class AdminLoginCredentials(
|
||||
override val id: Long,
|
||||
@ -22,18 +21,9 @@ data class AdminLoginCredentials(
|
||||
)
|
||||
}
|
||||
|
||||
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
||||
id = this.id,
|
||||
password = this.password,
|
||||
name = this.name,
|
||||
type = this.type,
|
||||
storeId = this.storeId,
|
||||
permissionLevel = this.permissionLevel
|
||||
)
|
||||
|
||||
data class AdminLoginSuccessResponse(
|
||||
override val accessToken: String,
|
||||
override val name: String,
|
||||
val type: AdminType,
|
||||
val storeId: Long?,
|
||||
) : LoginSuccessResponse()
|
||||
) : LoginSuccessResponse()
|
||||
@ -0,0 +1,13 @@
|
||||
package com.sangdol.roomescape.admin.mapper
|
||||
|
||||
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
||||
|
||||
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
||||
id = this.id,
|
||||
password = this.password,
|
||||
name = this.name,
|
||||
type = this.type,
|
||||
storeId = this.storeId,
|
||||
permissionLevel = this.permissionLevel
|
||||
)
|
||||
@ -1,15 +1,20 @@
|
||||
package com.sangdol.roomescape.auth.business
|
||||
|
||||
import com.sangdol.roomescape.admin.business.AdminService
|
||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.*
|
||||
import com.sangdol.roomescape.user.business.UserService
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -21,39 +26,40 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
||||
class AuthService(
|
||||
private val adminService: AdminService,
|
||||
private val userService: UserService,
|
||||
private val loginHistoryService: LoginHistoryService,
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun login(
|
||||
request: LoginRequest,
|
||||
context: LoginContext
|
||||
): LoginSuccessResponse {
|
||||
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||
log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||
val (credentials, extraClaims) = getCredentials(request)
|
||||
|
||||
val event = LoginHistoryEvent(
|
||||
id = credentials.id,
|
||||
type = request.principalType,
|
||||
ipAddress = context.ipAddress,
|
||||
userAgent = context.userAgent
|
||||
)
|
||||
|
||||
try {
|
||||
verifyPasswordOrThrow(request, credentials)
|
||||
|
||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||
|
||||
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
||||
eventPublisher.publishEvent(event.onSuccess())
|
||||
|
||||
return credentials.toResponse(accessToken).also {
|
||||
log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
||||
|
||||
eventPublisher.publishEvent(event.onFailure())
|
||||
when (e) {
|
||||
is AuthException -> {
|
||||
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" }
|
||||
throw e
|
||||
}
|
||||
is AuthException -> { throw e }
|
||||
|
||||
else -> {
|
||||
log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||
}
|
||||
}
|
||||
@ -65,7 +71,7 @@ class AuthService(
|
||||
credentials: LoginCredentials
|
||||
) {
|
||||
if (credentials.password != request.password) {
|
||||
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
package com.sangdol.roomescape.auth.business
|
||||
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||
import com.sangdol.roomescape.auth.mapper.toEntity
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class LoginHistoryEventListener(
|
||||
private val idGenerator: IDGenerator,
|
||||
private val loginHistoryRepository: LoginHistoryRepository,
|
||||
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
|
||||
) {
|
||||
|
||||
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
|
||||
private var batchSize: Int = 0
|
||||
|
||||
@Async
|
||||
@EventListener(classes = [LoginHistoryEvent::class])
|
||||
fun onLoginCompleted(event: LoginHistoryEvent) {
|
||||
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
|
||||
|
||||
queue.add(event.toEntity(idGenerator.create())).also {
|
||||
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
|
||||
}
|
||||
|
||||
if (queue.size >= batchSize) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
|
||||
fun flushScheduled() {
|
||||
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||
|
||||
if (queue.isEmpty()) {
|
||||
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
|
||||
return
|
||||
}
|
||||
flush()
|
||||
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun flushAll() {
|
||||
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
|
||||
while (!queue.isEmpty()) {
|
||||
flush()
|
||||
}
|
||||
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
|
||||
}
|
||||
|
||||
private fun flush() {
|
||||
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||
|
||||
if (queue.isEmpty()) {
|
||||
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
|
||||
return;
|
||||
}
|
||||
|
||||
val batch = mutableListOf<LoginHistoryEntity>()
|
||||
repeat(batchSize) {
|
||||
val entity: LoginHistoryEntity? = queue.poll()
|
||||
|
||||
if (entity != null) {
|
||||
batch.add(entity)
|
||||
} else {
|
||||
return@repeat
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.isEmpty()) {
|
||||
return
|
||||
}
|
||||
loginHistoryRepository.saveAll(batch).also {
|
||||
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
package com.sangdol.roomescape.auth.business
|
||||
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||
import com.sangdol.roomescape.auth.web.LoginContext
|
||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class LoginHistoryService(
|
||||
private val loginHistoryRepository: LoginHistoryRepository,
|
||||
private val idGenerator: IDGenerator,
|
||||
) {
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun createSuccessHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
context: LoginContext
|
||||
) {
|
||||
createHistory(principalId, principalType, success = true, context = context)
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun createFailureHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
context: LoginContext
|
||||
) {
|
||||
createHistory(principalId, principalType, success = false, context = context)
|
||||
}
|
||||
|
||||
private fun createHistory(
|
||||
principalId: Long,
|
||||
principalType: PrincipalType,
|
||||
success: Boolean,
|
||||
context: LoginContext
|
||||
) {
|
||||
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
|
||||
|
||||
runCatching {
|
||||
LoginHistoryEntity(
|
||||
id = idGenerator.create(),
|
||||
principalId = principalId,
|
||||
principalType = principalType,
|
||||
success = success,
|
||||
ipAddress = context.ipAddress,
|
||||
userAgent = context.userAgent,
|
||||
).also {
|
||||
loginHistoryRepository.save(it)
|
||||
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
|
||||
}
|
||||
}.onFailure {
|
||||
log.warn { "[LoginHistoryService] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.sangdol.roomescape.auth.business.domain
|
||||
|
||||
class LoginHistoryEvent(
|
||||
val id: Long,
|
||||
val type: PrincipalType,
|
||||
var success: Boolean = true,
|
||||
val ipAddress: String,
|
||||
val userAgent: String
|
||||
) {
|
||||
fun onSuccess(): LoginHistoryEvent {
|
||||
this.success = true
|
||||
return this
|
||||
}
|
||||
|
||||
fun onFailure(): LoginHistoryEvent {
|
||||
this.success = false
|
||||
return this
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.sangdol.roomescape.auth.business.domain
|
||||
|
||||
enum class PrincipalType {
|
||||
USER, ADMIN
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
package com.sangdol.roomescape.auth.docs
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import com.sangdol.roomescape.auth.web.LoginRequest
|
||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.web.support.Public
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
package com.sangdol.roomescape.auth.web
|
||||
package com.sangdol.roomescape.auth.dto
|
||||
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
|
||||
enum class PrincipalType {
|
||||
USER, ADMIN
|
||||
}
|
||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||
|
||||
data class LoginContext(
|
||||
val ipAddress: String,
|
||||
val userAgent: String,
|
||||
)
|
||||
|
||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||
ipAddress = this.remoteAddr,
|
||||
userAgent = this.getHeader("User-Agent")
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
val account: String,
|
||||
val password: String,
|
||||
@ -1,7 +1,7 @@
|
||||
package com.sangdol.roomescape.auth.exception
|
||||
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
|
||||
enum class AuthErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.sangdol.roomescape.auth.infrastructure.jwt
|
||||
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.jsonwebtoken.Claims
|
||||
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
|
||||
import io.jsonwebtoken.security.Keys
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
@ -50,7 +50,7 @@ class JwtUtils(
|
||||
val claims = extractAllClaims(token)
|
||||
|
||||
return claims.subject ?: run {
|
||||
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||
log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package com.sangdol.roomescape.auth.infrastructure.persistence
|
||||
|
||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@Table(name = "login_history")
|
||||
@ -24,5 +24,5 @@ class LoginHistoryEntity(
|
||||
|
||||
@Column(updatable = false)
|
||||
@CreatedDate
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
) : PersistableBaseEntity(id)
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package com.sangdol.roomescape.auth.mapper
|
||||
|
||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||
|
||||
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
|
||||
id = id,
|
||||
principalId = this.id,
|
||||
principalType = this.type,
|
||||
success = this.success,
|
||||
ipAddress = this.ipAddress,
|
||||
userAgent = this.userAgent
|
||||
)
|
||||
@ -3,6 +3,9 @@ package com.sangdol.roomescape.auth.web
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.auth.business.AuthService
|
||||
import com.sangdol.roomescape.auth.docs.AuthAPI
|
||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@ -36,3 +39,8 @@ class AuthController(
|
||||
return ResponseEntity.ok().build()
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||
ipAddress = this.remoteAddr,
|
||||
userAgent = this.getHeader("User-Agent")
|
||||
)
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
package com.sangdol.roomescape.auth.web.support.interceptors
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
||||
@ -17,7 +11,13 @@ import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -47,7 +47,10 @@ class AdminInterceptor(
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is AuthException -> { throw e }
|
||||
is AuthException -> {
|
||||
throw e
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
|
||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
package com.sangdol.roomescape.auth.web.support.interceptors
|
||||
|
||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@ -7,13 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -47,7 +47,10 @@ class UserInterceptor(
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is AuthException -> { throw e }
|
||||
is AuthException -> {
|
||||
throw e
|
||||
}
|
||||
|
||||
else -> {
|
||||
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
|
||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
package com.sangdol.roomescape.auth.web.support.resolver
|
||||
|
||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@ -9,19 +16,12 @@ import org.springframework.web.bind.support.WebDataBinderFactory
|
||||
import org.springframework.web.context.request.NativeWebRequest
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.method.support.ModelAndViewContainer
|
||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||
import com.sangdol.roomescape.auth.exception.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||
import com.sangdol.roomescape.user.business.UserService
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class UserContextResolver(
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val userService: UserService,
|
||||
) : HandlerMethodArgumentResolver {
|
||||
|
||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||
@ -38,9 +38,11 @@ class UserContextResolver(
|
||||
val token: String? = request.accessToken()
|
||||
|
||||
try {
|
||||
val id: Long = jwtUtils.extractSubject(token).toLong()
|
||||
val id: Long = jwtUtils.extractSubject(token).also {
|
||||
MdcPrincipalIdUtil.set(it)
|
||||
}.toLong()
|
||||
|
||||
return userService.findContextById(id)
|
||||
return CurrentUserContext(id = id)
|
||||
} catch (e: Exception) {
|
||||
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package com.sangdol.roomescape.common.config
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.annotation.PreDestroy
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
@Profile("!deploy & local")
|
||||
class LocalDatabaseCleaner(
|
||||
private val jdbcTemplate: JdbcTemplate
|
||||
) {
|
||||
@PreDestroy
|
||||
@Transactional
|
||||
fun clearAll() {
|
||||
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" }
|
||||
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||
|
||||
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
|
||||
rs.getString(1).lowercase()
|
||||
}.forEach {
|
||||
jdbcTemplate.execute("TRUNCATE TABLE $it")
|
||||
}
|
||||
|
||||
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" }
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
package com.sangdol.roomescape.common.config
|
||||
|
||||
import com.sangdol.common.web.config.JacksonConfig
|
||||
import com.sangdol.common.log.message.AbstractLogMaskingConverter
|
||||
import com.sangdol.common.web.config.JacksonConfig
|
||||
|
||||
class RoomescapeLogMaskingConverter: AbstractLogMaskingConverter(
|
||||
class RoomescapeLogMaskingConverter : AbstractLogMaskingConverter(
|
||||
sensitiveKeys = setOf("password", "accessToken", "phone"),
|
||||
objectMapper = JacksonConfig().objectMapper()
|
||||
)
|
||||
|
||||
@ -9,11 +9,9 @@ import org.springframework.boot.jdbc.DataSourceBuilder
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.context.annotation.Profile
|
||||
import javax.sql.DataSource
|
||||
|
||||
@Configuration
|
||||
@Profile("deploy")
|
||||
@EnableConfigurationProperties(SlowQueryProperties::class)
|
||||
class ProxyDataSourceConfig {
|
||||
|
||||
@ -36,7 +34,6 @@ class ProxyDataSourceConfig {
|
||||
.build()
|
||||
}
|
||||
|
||||
@Profile("deploy")
|
||||
@ConfigurationProperties(prefix = "slow-query")
|
||||
data class SlowQueryProperties(
|
||||
val loggerName: String,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package com.sangdol.roomescape.common.config
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI
|
||||
import io.swagger.v3.oas.models.info.Info
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
package com.sangdol.roomescape.common.config
|
||||
|
||||
import io.micrometer.observation.ObservationPredicate
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class TraceConfig {
|
||||
|
||||
companion object {
|
||||
val scheduleTaskName = "tasks.scheduled.execution"
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun excludeSchedulerPredicate(): ObservationPredicate {
|
||||
return ObservationPredicate { name, context ->
|
||||
!name.equals(scheduleTaskName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
package com.sangdol.roomescape.common.config
|
||||
|
||||
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
|
||||
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
|
||||
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
|
||||
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
|
||||
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
|
||||
|
||||
@Configuration
|
||||
class WebMvcConfig(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package com.sangdol.roomescape.common.types
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
|
||||
data class Auditor(
|
||||
val id: Long,
|
||||
@ -12,8 +12,8 @@ data class Auditor(
|
||||
}
|
||||
|
||||
data class AuditingInfo(
|
||||
val createdAt: LocalDateTime,
|
||||
val createdAt: Instant,
|
||||
val createdBy: Auditor,
|
||||
val updatedAt: LocalDateTime,
|
||||
val updatedAt: Instant,
|
||||
val updatedBy: Auditor,
|
||||
)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package com.sangdol.roomescape.common.types
|
||||
|
||||
data class CurrentUserContext(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val id: Long
|
||||
)
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package com.sangdol.roomescape.order.business
|
||||
|
||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||
import com.sangdol.roomescape.order.exception.OrderException
|
||||
import com.sangdol.roomescape.payment.business.PaymentService
|
||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
||||
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class OrderService(
|
||||
private val reservationService: ReservationService,
|
||||
private val scheduleService: ScheduleService,
|
||||
private val paymentService: PaymentService,
|
||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||
private val orderValidator: OrderValidator,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||
val paymentKey = paymentConfirmRequest.paymentKey
|
||||
|
||||
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||
try {
|
||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||
validateCanConfirm(reservationId)
|
||||
reservationService.markInProgress(reservationId)
|
||||
}
|
||||
|
||||
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
|
||||
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
|
||||
|
||||
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||
} catch (e: Exception) {
|
||||
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||
e.errorCode
|
||||
} else {
|
||||
OrderErrorCode.ORDER_UNEXPECTED_ERROR
|
||||
}
|
||||
|
||||
throw OrderException(errorCode, e.message ?: errorCode.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateCanConfirm(reservationId: Long) {
|
||||
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||
|
||||
try {
|
||||
orderValidator.validateCanConfirm(reservation, schedule)
|
||||
} catch (e: OrderException) {
|
||||
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||
throw OrderException(errorCode, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.sangdol.roomescape.order.business
|
||||
|
||||
import com.sangdol.common.utils.KoreaDateTime
|
||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||
import com.sangdol.roomescape.order.exception.OrderException
|
||||
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class OrderValidator {
|
||||
fun validateCanConfirm(
|
||||
reservation: ReservationStateResponse,
|
||||
schedule: ScheduleStateResponse
|
||||
) {
|
||||
validateReservationStatus(reservation)
|
||||
validateScheduleStatus(schedule)
|
||||
}
|
||||
|
||||
private fun validateReservationStatus(reservation: ReservationStateResponse) {
|
||||
when (reservation.status) {
|
||||
ReservationStatus.CONFIRMED -> {
|
||||
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
|
||||
}
|
||||
ReservationStatus.EXPIRED -> {
|
||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||
}
|
||||
ReservationStatus.CANCELED -> {
|
||||
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
|
||||
if (schedule.status != ScheduleStatus.HOLD) {
|
||||
log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
|
||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||
}
|
||||
|
||||
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||
val nowDateTime = KoreaDateTime.now()
|
||||
if (scheduleDateTime.isBefore(nowDateTime)) {
|
||||
log.debug { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
|
||||
throw OrderException(OrderErrorCode.PAST_SCHEDULE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.sangdol.roomescape.order.docs
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
|
||||
interface OrderAPI {
|
||||
|
||||
@UserOnly
|
||||
@Operation(summary = "결제 및 예약 완료 처리")
|
||||
@ApiResponses(ApiResponse(responseCode = "200"))
|
||||
fun confirm(
|
||||
@PathVariable("reservationId") reservationId: Long,
|
||||
@RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>>
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.sangdol.roomescape.order.exception
|
||||
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
|
||||
enum class OrderErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
override val errorCode: String,
|
||||
override val message: String
|
||||
) : ErrorCode {
|
||||
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||
|
||||
ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||
;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.sangdol.roomescape.order.exception
|
||||
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
|
||||
class OrderException(
|
||||
override val errorCode: ErrorCode,
|
||||
override val message: String = errorCode.message,
|
||||
) : RoomescapeException(errorCode, message)
|
||||
@ -0,0 +1,25 @@
|
||||
package com.sangdol.roomescape.order.web
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.order.business.OrderService
|
||||
import com.sangdol.roomescape.order.docs.OrderAPI
|
||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/orders")
|
||||
class OrderController(
|
||||
private val orderService: OrderService
|
||||
) : OrderAPI {
|
||||
|
||||
@PostMapping("/{reservationId}/confirm")
|
||||
override fun confirm(
|
||||
@PathVariable("reservationId") reservationId: Long,
|
||||
@RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
orderService.confirm(reservationId, request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse())
|
||||
}
|
||||
}
|
||||
@ -1,131 +1,153 @@
|
||||
package com.sangdol.roomescape.payment.business
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.dto.*
|
||||
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
import com.sangdol.roomescape.payment.web.*
|
||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
||||
import com.sangdol.roomescape.payment.mapper.toResponse
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class PaymentService(
|
||||
private val idGenerator: IDGenerator,
|
||||
private val paymentClient: TosspayClient,
|
||||
private val paymentRepository: PaymentRepository,
|
||||
private val paymentDetailRepository: PaymentDetailRepository,
|
||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||
private val paymentWriter: PaymentWriter,
|
||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
|
||||
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
|
||||
paymentKey = request.paymentKey,
|
||||
orderId = request.orderId,
|
||||
amount = request.amount,
|
||||
)
|
||||
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||
try {
|
||||
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
||||
eventPublisher.publishEvent(it.toEvent(reservationId))
|
||||
log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when(e) {
|
||||
is ExternalPaymentException -> {
|
||||
val errorCode = if (e.httpStatusCode in 400..<500) {
|
||||
PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
||||
} else {
|
||||
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
|
||||
}
|
||||
|
||||
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||
val payment: PaymentEntity = paymentWriter.createPayment(
|
||||
reservationId = reservationId,
|
||||
orderId = request.orderId,
|
||||
paymentType = request.paymentType,
|
||||
paymentClientConfirmResponse = clientConfirmResponse
|
||||
)
|
||||
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
|
||||
val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
|
||||
"${errorCode.message}(${e.message})"
|
||||
} else {
|
||||
errorCode.message
|
||||
}
|
||||
|
||||
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
|
||||
} ?: run {
|
||||
log.warn { "[PaymentService.confirm] 결제 확정 중 예상치 못한 null 반환" }
|
||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
throw PaymentException(errorCode, message)
|
||||
}
|
||||
else -> {
|
||||
log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
|
||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(userId: Long, request: PaymentCancelRequest) {
|
||||
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
||||
|
||||
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
|
||||
val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
|
||||
paymentKey = payment.paymentKey,
|
||||
amount = payment.totalAmount,
|
||||
cancelReason = request.cancelReason
|
||||
)
|
||||
|
||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||
paymentWriter.cancel(
|
||||
userId = userId,
|
||||
payment = payment,
|
||||
requestedAt = request.requestedAt,
|
||||
cancelResponse = clientCancelResponse
|
||||
)
|
||||
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
|
||||
|
||||
clientCancelResponse.cancels.toEntity(
|
||||
id = idGenerator.create(),
|
||||
paymentId = payment.id,
|
||||
cancelRequestedAt = request.requestedAt,
|
||||
canceledBy = userId
|
||||
).also {
|
||||
canceledPaymentRepository.save(it)
|
||||
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
||||
}
|
||||
}.also {
|
||||
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
|
||||
log.info { "[PaymentService.findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
||||
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
||||
log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
||||
|
||||
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
|
||||
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
|
||||
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
|
||||
|
||||
return payment?.toDetailResponse(
|
||||
detail = paymentDetail?.toPaymentDetailResponse(),
|
||||
cancel = cancelDetail?.toCancelDetailResponse()
|
||||
)
|
||||
return payment?.toResponse(
|
||||
detail = paymentDetail?.toResponse(),
|
||||
cancel = cancelDetail?.toResponse()
|
||||
).also {
|
||||
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
||||
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||
|
||||
return paymentRepository.findByReservationId(reservationId)
|
||||
?.also { log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
||||
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
||||
?: run {
|
||||
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
|
||||
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
|
||||
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
|
||||
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||
|
||||
return paymentRepository.findByReservationId(reservationId)
|
||||
.also {
|
||||
if (it != null) {
|
||||
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
|
||||
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
|
||||
} else {
|
||||
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
|
||||
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
|
||||
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
||||
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
||||
|
||||
return paymentDetailRepository.findByPaymentId(paymentId).also {
|
||||
if (it != null) {
|
||||
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
|
||||
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
|
||||
} else {
|
||||
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
|
||||
log.warn { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
|
||||
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
||||
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
||||
|
||||
return canceledPaymentRepository.findByPaymentId(paymentId).also {
|
||||
if (it == null) {
|
||||
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
|
||||
log.info { "[findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
|
||||
} else {
|
||||
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" }
|
||||
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
package com.sangdol.roomescape.payment.business
|
||||
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.*
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class PaymentWriter(
|
||||
private val paymentRepository: PaymentRepository,
|
||||
private val paymentDetailRepository: PaymentDetailRepository,
|
||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||
private val idGenerator: IDGenerator,
|
||||
) {
|
||||
|
||||
fun createPayment(
|
||||
reservationId: Long,
|
||||
orderId: String,
|
||||
paymentType: PaymentType,
|
||||
paymentClientConfirmResponse: PaymentClientConfirmResponse
|
||||
): PaymentEntity {
|
||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
|
||||
|
||||
return paymentClientConfirmResponse.toEntity(
|
||||
id = idGenerator.create(), reservationId, orderId, paymentType
|
||||
).also {
|
||||
paymentRepository.save(it)
|
||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun createDetail(
|
||||
paymentResponse: PaymentClientConfirmResponse,
|
||||
paymentId: Long,
|
||||
): PaymentDetailEntity {
|
||||
val method: PaymentMethod = paymentResponse.method
|
||||
val id = idGenerator.create()
|
||||
|
||||
if (method == PaymentMethod.TRANSFER) {
|
||||
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId))
|
||||
}
|
||||
if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) {
|
||||
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||
}
|
||||
if (paymentResponse.card != null) {
|
||||
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId))
|
||||
}
|
||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
|
||||
fun cancel(
|
||||
userId: Long,
|
||||
payment: PaymentEntity,
|
||||
requestedAt: LocalDateTime,
|
||||
cancelResponse: PaymentClientCancelResponse
|
||||
): CanceledPaymentEntity {
|
||||
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
||||
|
||||
paymentRepository.save(payment.apply { this.cancel() })
|
||||
|
||||
return cancelResponse.cancels.toEntity(
|
||||
id = idGenerator.create(),
|
||||
paymentId = payment.id,
|
||||
cancelRequestedAt = requestedAt,
|
||||
canceledBy = userId
|
||||
).also {
|
||||
canceledPaymentRepository.save(it)
|
||||
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.common
|
||||
package com.sangdol.roomescape.payment.business.domain
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.sangdol.roomescape.payment.business.domain
|
||||
|
||||
abstract class PaymentDetail
|
||||
|
||||
class BankTransferPaymentDetail(
|
||||
val bankCode: BankCode,
|
||||
val settlementStatus: String,
|
||||
): PaymentDetail()
|
||||
|
||||
class CardPaymentDetail(
|
||||
val issuerCode: CardIssuerCode,
|
||||
val number: String,
|
||||
val amount: Int,
|
||||
val cardType: CardType,
|
||||
val ownerType: CardOwnerType,
|
||||
val isInterestFree: Boolean,
|
||||
val approveNo: String,
|
||||
val installmentPlanMonths: Int
|
||||
): PaymentDetail()
|
||||
|
||||
class EasypayCardPaymentDetail(
|
||||
val issuerCode: CardIssuerCode,
|
||||
val number: String,
|
||||
val amount: Int,
|
||||
val cardType: CardType,
|
||||
val ownerType: CardOwnerType,
|
||||
val isInterestFree: Boolean,
|
||||
val approveNo: String,
|
||||
val installmentPlanMonths: Int,
|
||||
val easypayProvider: EasyPayCompanyCode,
|
||||
val easypayDiscountAmount: Int,
|
||||
): PaymentDetail()
|
||||
|
||||
class EasypayPrepaidPaymentDetail(
|
||||
val provider: EasyPayCompanyCode,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
): PaymentDetail()
|
||||
@ -0,0 +1,42 @@
|
||||
package com.sangdol.roomescape.payment.business.domain
|
||||
|
||||
enum class UserFacingPaymentErrorCode {
|
||||
ALREADY_PROCESSED_PAYMENT,
|
||||
EXCEED_MAX_CARD_INSTALLMENT_PLAN,
|
||||
NOT_ALLOWED_POINT_USE,
|
||||
INVALID_REJECT_CARD,
|
||||
BELOW_MINIMUM_AMOUNT,
|
||||
INVALID_CARD_EXPIRATION,
|
||||
INVALID_STOPPED_CARD,
|
||||
EXCEED_MAX_DAILY_PAYMENT_COUNT,
|
||||
NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT,
|
||||
INVALID_CARD_INSTALLMENT_PLAN,
|
||||
NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN,
|
||||
EXCEED_MAX_PAYMENT_AMOUNT,
|
||||
INVALID_CARD_LOST_OR_STOLEN,
|
||||
RESTRICTED_TRANSFER_ACCOUNT,
|
||||
INVALID_CARD_NUMBER,
|
||||
EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT,
|
||||
EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT,
|
||||
CARD_PROCESSING_ERROR,
|
||||
EXCEED_MAX_AMOUNT,
|
||||
INVALID_ACCOUNT_INFO_RE_REGISTER,
|
||||
NOT_AVAILABLE_PAYMENT,
|
||||
EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT,
|
||||
REJECT_ACCOUNT_PAYMENT,
|
||||
REJECT_CARD_PAYMENT,
|
||||
REJECT_CARD_COMPANY,
|
||||
FORBIDDEN_REQUEST,
|
||||
EXCEED_MAX_AUTH_COUNT,
|
||||
EXCEED_MAX_ONE_DAY_AMOUNT,
|
||||
NOT_AVAILABLE_BANK,
|
||||
INVALID_PASSWORD,
|
||||
FDS_ERROR,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun contains(code: String): Boolean {
|
||||
return entries.any { it.name == code }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.sangdol.roomescape.payment.business.event
|
||||
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentDetail
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||
import java.time.Instant
|
||||
|
||||
class PaymentEvent(
|
||||
val reservationId: Long,
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val type: PaymentType,
|
||||
val status: PaymentStatus,
|
||||
val totalAmount: Int,
|
||||
val vat: Int,
|
||||
val suppliedAmount: Int,
|
||||
val method: PaymentMethod,
|
||||
val requestedAt: Instant,
|
||||
val approvedAt: Instant,
|
||||
val detail: PaymentDetail
|
||||
)
|
||||
@ -0,0 +1,44 @@
|
||||
package com.sangdol.roomescape.payment.business.event
|
||||
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class PaymentEventListener(
|
||||
private val idGenerator: IDGenerator,
|
||||
private val paymentRepository: PaymentRepository,
|
||||
private val paymentDetailRepository: PaymentDetailRepository
|
||||
) {
|
||||
|
||||
@Async
|
||||
@EventListener
|
||||
@Transactional
|
||||
fun handlePaymentEvent(event: PaymentEvent) {
|
||||
val reservationId = event.reservationId
|
||||
|
||||
log.debug { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" }
|
||||
|
||||
val paymentId = idGenerator.create()
|
||||
val paymentEntity: PaymentEntity = event.toEntity(paymentId)
|
||||
paymentRepository.save(paymentEntity)
|
||||
|
||||
val paymentDetailId = idGenerator.create()
|
||||
val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId)
|
||||
paymentDetailRepository.save(paymentDetailEntity)
|
||||
|
||||
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료: reservationId=${reservationId}, paymentId=${paymentId}, paymentDetailId=${paymentDetailId}" }
|
||||
}
|
||||
}
|
||||
@ -2,29 +2,17 @@ package com.sangdol.roomescape.payment.docs
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import com.sangdol.roomescape.payment.web.PaymentCancelRequest
|
||||
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
|
||||
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
|
||||
interface PaymentAPI {
|
||||
|
||||
@UserOnly
|
||||
@Operation(summary = "결제 승인")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun confirmPayment(
|
||||
@RequestParam(required = true) reservationId: Long,
|
||||
@Valid @RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
|
||||
|
||||
@Operation(summary = "결제 취소")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun cancelPayment(
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
package com.sangdol.roomescape.payment.dto
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import com.sangdol.roomescape.payment.business.domain.*
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.CancelDetailDeserializer
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentGatewayResponse(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val type: PaymentType,
|
||||
val status: PaymentStatus,
|
||||
val totalAmount: Int,
|
||||
val vat: Int,
|
||||
val suppliedAmount: Int,
|
||||
val method: PaymentMethod,
|
||||
val card: CardDetailResponse?,
|
||||
val easyPay: EasyPayDetailResponse?,
|
||||
val transfer: TransferDetailResponse?,
|
||||
val requestedAt: OffsetDateTime,
|
||||
val approvedAt: OffsetDateTime,
|
||||
)
|
||||
|
||||
data class PaymentGatewayCancelResponse(
|
||||
val status: PaymentStatus,
|
||||
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
||||
val cancels: CancelDetail,
|
||||
)
|
||||
|
||||
data class CardDetailResponse(
|
||||
val issuerCode: CardIssuerCode,
|
||||
val number: String,
|
||||
val amount: Int,
|
||||
val cardType: CardType,
|
||||
val ownerType: CardOwnerType,
|
||||
val isInterestFree: Boolean,
|
||||
val approveNo: String,
|
||||
val installmentPlanMonths: Int
|
||||
)
|
||||
|
||||
data class EasyPayDetailResponse(
|
||||
val provider: EasyPayCompanyCode,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
)
|
||||
|
||||
data class TransferDetailResponse(
|
||||
val bankCode: BankCode,
|
||||
val settlementStatus: String,
|
||||
)
|
||||
|
||||
data class CancelDetail(
|
||||
val cancelAmount: Int,
|
||||
val cardDiscountAmount: Int,
|
||||
val transferDiscountAmount: Int,
|
||||
val easyPayDiscountAmount: Int,
|
||||
val canceledAt: OffsetDateTime,
|
||||
val cancelReason: String
|
||||
)
|
||||
@ -0,0 +1,49 @@
|
||||
package com.sangdol.roomescape.payment.dto
|
||||
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||
import java.time.Instant
|
||||
|
||||
data class PaymentResponse(
|
||||
val orderId: String,
|
||||
val totalAmount: Int,
|
||||
val method: String,
|
||||
val status: PaymentStatus,
|
||||
val requestedAt: Instant,
|
||||
val approvedAt: Instant,
|
||||
val detail: PaymentDetailResponse?,
|
||||
val cancel: PaymentCancelDetailResponse?,
|
||||
)
|
||||
|
||||
sealed class PaymentDetailResponse {
|
||||
data class CardDetailResponse(
|
||||
val type: String = "CARD",
|
||||
val issuerCode: String,
|
||||
val cardType: String,
|
||||
val ownerType: String,
|
||||
val cardNumber: String,
|
||||
val amount: Int,
|
||||
val approvalNumber: String,
|
||||
val installmentPlanMonths: Int,
|
||||
val easypayProviderName: String?,
|
||||
val easypayDiscountAmount: Int?,
|
||||
) : PaymentDetailResponse()
|
||||
|
||||
data class BankTransferDetailResponse(
|
||||
val type: String = "BANK_TRANSFER",
|
||||
val bankName: String,
|
||||
) : PaymentDetailResponse()
|
||||
|
||||
data class EasyPayPrepaidDetailResponse(
|
||||
val type: String = "EASYPAY_PREPAID",
|
||||
val providerName: String,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
) : PaymentDetailResponse()
|
||||
}
|
||||
|
||||
data class PaymentCancelDetailResponse(
|
||||
val cancellationRequestedAt: Instant,
|
||||
val cancellationApprovedAt: Instant?,
|
||||
val cancelReason: String,
|
||||
val canceledBy: Long,
|
||||
)
|
||||
@ -0,0 +1,15 @@
|
||||
package com.sangdol.roomescape.payment.dto
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class PaymentConfirmRequest(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val amount: Int,
|
||||
)
|
||||
|
||||
data class PaymentCancelRequest(
|
||||
val reservationId: Long,
|
||||
val cancelReason: String,
|
||||
val requestedAt: Instant = Instant.now()
|
||||
)
|
||||
@ -1,7 +1,7 @@
|
||||
package com.sangdol.roomescape.payment.exception
|
||||
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
|
||||
enum class PaymentErrorCode(
|
||||
override val httpStatus: HttpStatus,
|
||||
|
||||
@ -6,3 +6,9 @@ class PaymentException(
|
||||
override val errorCode: PaymentErrorCode,
|
||||
override val message: String = errorCode.message
|
||||
) : RoomescapeException(errorCode, message)
|
||||
|
||||
class ExternalPaymentException(
|
||||
val httpStatusCode: Int,
|
||||
val errorCode: String,
|
||||
override val message: String
|
||||
) : RuntimeException(message)
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
package com.sangdol.roomescape.payment.exception
|
||||
|
||||
import com.sangdol.common.types.web.CommonErrorResponse
|
||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@RestControllerAdvice
|
||||
class PaymentExceptionHandler(
|
||||
private val logMessageConverter: WebLogMessageConverter
|
||||
) {
|
||||
@ExceptionHandler(PaymentException::class)
|
||||
fun handlePaymentException(
|
||||
servletRequest: HttpServletRequest,
|
||||
e: PaymentException
|
||||
): ResponseEntity<CommonErrorResponse> {
|
||||
val errorCode = e.errorCode
|
||||
val httpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode, e.message)
|
||||
|
||||
log.warn {
|
||||
logMessageConverter.convertToErrorResponseMessage(
|
||||
servletRequest = servletRequest,
|
||||
httpStatus = httpStatus,
|
||||
responseBody = errorResponse,
|
||||
exception = if (e.message == errorCode.message) null else e
|
||||
)
|
||||
}
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
.body(errorResponse)
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.client
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentClientCancelResponse(
|
||||
val status: PaymentStatus,
|
||||
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
||||
val cancels: CancelDetail,
|
||||
)
|
||||
|
||||
data class CancelDetail(
|
||||
val cancelAmount: Int,
|
||||
val cardDiscountAmount: Int,
|
||||
val transferDiscountAmount: Int,
|
||||
val easyPayDiscountAmount: Int,
|
||||
val canceledAt: OffsetDateTime,
|
||||
val cancelReason: String
|
||||
)
|
||||
|
||||
fun CancelDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
canceledBy: Long,
|
||||
cancelRequestedAt: LocalDateTime
|
||||
) = CanceledPaymentEntity(
|
||||
id = id,
|
||||
canceledAt = this.canceledAt,
|
||||
requestedAt = cancelRequestedAt,
|
||||
paymentId = paymentId,
|
||||
canceledBy = canceledBy,
|
||||
cancelReason = this.cancelReason,
|
||||
cancelAmount = this.cancelAmount,
|
||||
cardDiscountAmount = this.cardDiscountAmount,
|
||||
transferDiscountAmount = this.transferDiscountAmount,
|
||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
||||
)
|
||||
|
||||
class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
|
||||
override fun deserialize(
|
||||
p: JsonParser,
|
||||
ctxt: DeserializationContext
|
||||
): CancelDetail? {
|
||||
val node: JsonNode = p.codec.readTree(p) ?: return null
|
||||
|
||||
val targetNode = when {
|
||||
node.isArray && !node.isEmpty -> node[0]
|
||||
node.isObject -> node
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return CancelDetail(
|
||||
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
||||
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
||||
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
||||
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
||||
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
||||
cancelReason = targetNode.get("cancelReason").asText()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.client
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
@ -28,9 +31,9 @@ class TosspayClient(
|
||||
paymentKey: String,
|
||||
orderId: String,
|
||||
amount: Int,
|
||||
): PaymentClientConfirmResponse {
|
||||
): PaymentGatewayResponse {
|
||||
val startTime = System.currentTimeMillis()
|
||||
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
||||
log.debug { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
||||
|
||||
return confirmClient.request(paymentKey, orderId, amount)
|
||||
.also {
|
||||
@ -42,9 +45,9 @@ class TosspayClient(
|
||||
paymentKey: String,
|
||||
amount: Int,
|
||||
cancelReason: String
|
||||
): PaymentClientCancelResponse {
|
||||
): PaymentGatewayCancelResponse {
|
||||
val startTime = System.currentTimeMillis()
|
||||
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
||||
log.debug { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
||||
|
||||
return cancelClient.request(paymentKey, amount, cancelReason).also {
|
||||
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
|
||||
@ -62,7 +65,7 @@ private class ConfirmClient(
|
||||
|
||||
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
|
||||
|
||||
fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse {
|
||||
fun request(paymentKey: String, orderId: String, amount: Int): PaymentGatewayResponse {
|
||||
val response = client.post()
|
||||
.uri(CONFIRM_URI)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@ -83,7 +86,7 @@ private class ConfirmClient(
|
||||
|
||||
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
|
||||
|
||||
return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java)
|
||||
return objectMapper.readValue(response, PaymentGatewayResponse::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +104,7 @@ private class CancelClient(
|
||||
paymentKey: String,
|
||||
amount: Int,
|
||||
cancelReason: String
|
||||
): PaymentClientCancelResponse {
|
||||
): PaymentGatewayCancelResponse {
|
||||
val response = client.post()
|
||||
.uri(CANCEL_URI, paymentKey)
|
||||
.body(
|
||||
@ -119,7 +122,7 @@ private class CancelClient(
|
||||
}
|
||||
|
||||
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
|
||||
return objectMapper.readValue(response, PaymentClientCancelResponse::class.java)
|
||||
return objectMapper.readValue(response, PaymentGatewayCancelResponse::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,9 +141,20 @@ private class TosspayErrorHandler(
|
||||
response: ClientHttpResponse
|
||||
): Nothing {
|
||||
val requestType: String = paymentRequestType(url)
|
||||
log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" }
|
||||
val errorResponse: TosspayErrorResponse = parseResponse(response)
|
||||
val status = response.statusCode
|
||||
|
||||
throw PaymentException(paymentErrorCode(response.statusCode))
|
||||
if (status.is5xxServerError) {
|
||||
log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
|
||||
} else {
|
||||
log.info { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
|
||||
}
|
||||
|
||||
throw ExternalPaymentException(
|
||||
httpStatusCode = status.value(),
|
||||
errorCode = errorResponse.code,
|
||||
message = errorResponse.message
|
||||
)
|
||||
}
|
||||
|
||||
private fun paymentRequestType(url: URI): String {
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.client
|
||||
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentClientConfirmResponse(
|
||||
val paymentKey: String,
|
||||
val status: PaymentStatus,
|
||||
val totalAmount: Int,
|
||||
val vat: Int,
|
||||
val suppliedAmount: Int,
|
||||
val method: PaymentMethod,
|
||||
val card: CardDetail?,
|
||||
val easyPay: EasyPayDetail?,
|
||||
val transfer: TransferDetail?,
|
||||
val requestedAt: OffsetDateTime,
|
||||
val approvedAt: OffsetDateTime,
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toEntity(
|
||||
id: Long,
|
||||
reservationId: Long,
|
||||
orderId: String,
|
||||
paymentType: PaymentType
|
||||
) = PaymentEntity(
|
||||
id = id,
|
||||
reservationId = reservationId,
|
||||
paymentKey = this.paymentKey,
|
||||
orderId = orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
requestedAt = this.requestedAt,
|
||||
approvedAt = this.approvedAt,
|
||||
type = paymentType,
|
||||
method = this.method,
|
||||
status = this.status,
|
||||
)
|
||||
|
||||
data class CardDetail(
|
||||
val issuerCode: CardIssuerCode,
|
||||
val number: String,
|
||||
val amount: Int,
|
||||
val cardType: CardType,
|
||||
val ownerType: CardOwnerType,
|
||||
val isInterestFree: Boolean,
|
||||
val approveNo: String,
|
||||
val installmentPlanMonths: Int
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return PaymentCardDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = this.suppliedAmount,
|
||||
vat = this.vat,
|
||||
issuerCode = cardDetail.issuerCode,
|
||||
cardType = cardDetail.cardType,
|
||||
ownerType = cardDetail.ownerType,
|
||||
amount = cardDetail.amount,
|
||||
cardNumber = cardDetail.number,
|
||||
approvalNumber = cardDetail.approveNo,
|
||||
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
||||
isInterestFree = cardDetail.isInterestFree,
|
||||
easypayProviderCode = this.easyPay?.provider,
|
||||
easypayDiscountAmount = this.easyPay?.discountAmount,
|
||||
)
|
||||
}
|
||||
|
||||
data class EasyPayDetail(
|
||||
val provider: EasyPayCompanyCode,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
||||
id: Long,
|
||||
paymentId: Long
|
||||
): PaymentEasypayPrepaidDetailEntity {
|
||||
val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return PaymentEasypayPrepaidDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = this.suppliedAmount,
|
||||
vat = this.vat,
|
||||
easypayProviderCode = easyPayDetail.provider,
|
||||
amount = easyPayDetail.amount,
|
||||
discountAmount = easyPayDetail.discountAmount
|
||||
)
|
||||
}
|
||||
|
||||
data class TransferDetail(
|
||||
val bankCode: BankCode,
|
||||
val settlementStatus: String,
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
||||
id: Long,
|
||||
paymentId: Long
|
||||
): PaymentBankTransferDetailEntity {
|
||||
val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return PaymentBankTransferDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = this.suppliedAmount,
|
||||
vat = this.vat,
|
||||
bankCode = transferDetail.bankCode,
|
||||
settlementStatus = transferDetail.settlementStatus
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.client
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
|
||||
override fun deserialize(
|
||||
p: JsonParser,
|
||||
ctxt: DeserializationContext
|
||||
): CancelDetail? {
|
||||
val node: JsonNode = p.codec.readTree(p) ?: return null
|
||||
|
||||
val targetNode = when {
|
||||
node.isArray && !node.isEmpty -> node[0]
|
||||
node.isObject -> node
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return CancelDetail(
|
||||
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
||||
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
||||
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
||||
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
||||
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
||||
cancelReason = targetNode.get("cancelReason").asText()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,7 @@ package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@Table(name = "canceled_payment")
|
||||
@ -12,8 +11,8 @@ class CanceledPaymentEntity(
|
||||
id: Long,
|
||||
|
||||
val paymentId: Long,
|
||||
val requestedAt: LocalDateTime,
|
||||
val canceledAt: OffsetDateTime,
|
||||
val requestedAt: Instant,
|
||||
val canceledAt: Instant,
|
||||
val canceledBy: Long,
|
||||
val cancelReason: String,
|
||||
val cancelAmount: Int,
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||
|
||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
||||
import com.sangdol.roomescape.payment.business.domain.BankCode
|
||||
import com.sangdol.roomescape.payment.business.domain.CardIssuerCode
|
||||
import com.sangdol.roomescape.payment.business.domain.CardOwnerType
|
||||
import com.sangdol.roomescape.payment.business.domain.CardType
|
||||
import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode
|
||||
import jakarta.persistence.*
|
||||
|
||||
@Entity
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||
|
||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Table
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@Table(name = "payment")
|
||||
@ -19,8 +19,8 @@ class PaymentEntity(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val totalAmount: Int,
|
||||
val requestedAt: OffsetDateTime,
|
||||
val approvedAt: OffsetDateTime,
|
||||
val requestedAt: Instant,
|
||||
val approvedAt: Instant,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
val type: PaymentType,
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
package com.sangdol.roomescape.payment.mapper
|
||||
|
||||
import com.sangdol.roomescape.payment.business.domain.*
|
||||
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
||||
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||
import java.time.Instant
|
||||
|
||||
fun CancelDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
canceledBy: Long,
|
||||
cancelRequestedAt: Instant
|
||||
) = CanceledPaymentEntity(
|
||||
id = id,
|
||||
canceledAt = this.canceledAt.toInstant(),
|
||||
requestedAt = cancelRequestedAt,
|
||||
paymentId = paymentId,
|
||||
canceledBy = canceledBy,
|
||||
cancelReason = this.cancelReason,
|
||||
cancelAmount = this.cancelAmount,
|
||||
cardDiscountAmount = this.cardDiscountAmount,
|
||||
transferDiscountAmount = this.transferDiscountAmount,
|
||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
||||
)
|
||||
|
||||
fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent {
|
||||
return PaymentEvent(
|
||||
reservationId = reservationId,
|
||||
paymentKey = this.paymentKey,
|
||||
orderId = this.orderId,
|
||||
type = this.type,
|
||||
status = this.status,
|
||||
totalAmount = this.totalAmount,
|
||||
vat = this.vat,
|
||||
suppliedAmount = this.suppliedAmount,
|
||||
method = this.method,
|
||||
requestedAt = this.requestedAt.toInstant(),
|
||||
approvedAt = this.approvedAt.toInstant(),
|
||||
detail = this.toDetail()
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentGatewayResponse.toDetail(): PaymentDetail {
|
||||
return when (this.method) {
|
||||
PaymentMethod.TRANSFER -> this.toBankTransferDetail()
|
||||
PaymentMethod.CARD -> this.toCardDetail()
|
||||
PaymentMethod.EASY_PAY -> {
|
||||
if (this.card != null) {
|
||||
this.toEasypayCardDetail()
|
||||
} else {
|
||||
this.toEasypayPrepaidDetail()
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PaymentGatewayResponse.toBankTransferDetail(): BankTransferPaymentDetail {
|
||||
val bankTransfer = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return BankTransferPaymentDetail(
|
||||
bankCode = bankTransfer.bankCode,
|
||||
settlementStatus = bankTransfer.settlementStatus
|
||||
)
|
||||
}
|
||||
|
||||
private fun PaymentGatewayResponse.toCardDetail(): CardPaymentDetail {
|
||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return CardPaymentDetail(
|
||||
issuerCode = cardDetail.issuerCode,
|
||||
number = cardDetail.number,
|
||||
amount = cardDetail.amount,
|
||||
cardType = cardDetail.cardType,
|
||||
ownerType = cardDetail.ownerType,
|
||||
isInterestFree = cardDetail.isInterestFree,
|
||||
approveNo = cardDetail.approveNo,
|
||||
installmentPlanMonths = cardDetail.installmentPlanMonths
|
||||
)
|
||||
}
|
||||
|
||||
private fun PaymentGatewayResponse.toEasypayCardDetail(): EasypayCardPaymentDetail {
|
||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return EasypayCardPaymentDetail(
|
||||
issuerCode = cardDetail.issuerCode,
|
||||
number = cardDetail.number,
|
||||
amount = cardDetail.amount,
|
||||
cardType = cardDetail.cardType,
|
||||
ownerType = cardDetail.ownerType,
|
||||
isInterestFree = cardDetail.isInterestFree,
|
||||
approveNo = cardDetail.approveNo,
|
||||
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
||||
easypayProvider = easypay.provider,
|
||||
easypayDiscountAmount = easypay.discountAmount
|
||||
)
|
||||
}
|
||||
|
||||
private fun PaymentGatewayResponse.toEasypayPrepaidDetail(): EasypayPrepaidPaymentDetail {
|
||||
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return EasypayPrepaidPaymentDetail(
|
||||
provider = easypay.provider,
|
||||
amount = easypay.amount,
|
||||
discountAmount = easypay.discountAmount
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.sangdol.roomescape.payment.mapper
|
||||
|
||||
import com.sangdol.roomescape.payment.business.domain.BankTransferPaymentDetail
|
||||
import com.sangdol.roomescape.payment.business.domain.CardPaymentDetail
|
||||
import com.sangdol.roomescape.payment.business.domain.EasypayCardPaymentDetail
|
||||
import com.sangdol.roomescape.payment.business.domain.EasypayPrepaidPaymentDetail
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
||||
|
||||
fun BankTransferPaymentDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
suppliedAmount: Int,
|
||||
vat: Int
|
||||
): PaymentDetailEntity {
|
||||
return PaymentBankTransferDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = suppliedAmount,
|
||||
vat = vat,
|
||||
bankCode = this.bankCode,
|
||||
settlementStatus = this.settlementStatus
|
||||
)
|
||||
}
|
||||
|
||||
fun CardPaymentDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
suppliedAmount: Int,
|
||||
vat: Int
|
||||
): PaymentDetailEntity {
|
||||
return PaymentCardDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = suppliedAmount,
|
||||
vat = vat,
|
||||
issuerCode = issuerCode,
|
||||
cardType = cardType,
|
||||
ownerType = ownerType,
|
||||
amount = amount,
|
||||
cardNumber = this.number,
|
||||
approvalNumber = this.approveNo,
|
||||
installmentPlanMonths = installmentPlanMonths,
|
||||
isInterestFree = isInterestFree,
|
||||
easypayProviderCode = null,
|
||||
easypayDiscountAmount = null
|
||||
)
|
||||
}
|
||||
|
||||
fun EasypayCardPaymentDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
suppliedAmount: Int,
|
||||
vat: Int
|
||||
): PaymentDetailEntity {
|
||||
return PaymentCardDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = suppliedAmount,
|
||||
vat = vat,
|
||||
issuerCode = issuerCode,
|
||||
cardType = cardType,
|
||||
ownerType = ownerType,
|
||||
amount = amount,
|
||||
cardNumber = this.number,
|
||||
approvalNumber = this.approveNo,
|
||||
installmentPlanMonths = installmentPlanMonths,
|
||||
isInterestFree = isInterestFree,
|
||||
easypayProviderCode = this.easypayProvider,
|
||||
easypayDiscountAmount = this.easypayDiscountAmount
|
||||
)
|
||||
}
|
||||
|
||||
fun EasypayPrepaidPaymentDetail.toEntity(
|
||||
id: Long,
|
||||
paymentId: Long,
|
||||
suppliedAmount: Int,
|
||||
vat: Int
|
||||
): PaymentDetailEntity {
|
||||
return PaymentEasypayPrepaidDetailEntity(
|
||||
id = id,
|
||||
paymentId = paymentId,
|
||||
suppliedAmount = suppliedAmount,
|
||||
vat = vat,
|
||||
easypayProviderCode = this.provider,
|
||||
amount = this.amount,
|
||||
discountAmount = this.discountAmount
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package com.sangdol.roomescape.payment.mapper
|
||||
|
||||
import com.sangdol.roomescape.payment.business.domain.*
|
||||
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||
|
||||
fun PaymentEvent.toEntity(id: Long) = PaymentEntity(
|
||||
id = id,
|
||||
reservationId = this.reservationId,
|
||||
paymentKey = this.paymentKey,
|
||||
orderId = this.orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
requestedAt = this.requestedAt,
|
||||
approvedAt = this.approvedAt,
|
||||
type = this.type,
|
||||
method = this.method,
|
||||
status = this.status
|
||||
)
|
||||
|
||||
fun PaymentEvent.toDetailEntity(id: Long, paymentId: Long): PaymentDetailEntity {
|
||||
val suppliedAmount = this.suppliedAmount
|
||||
val vat = this.vat
|
||||
|
||||
return when (this.method) {
|
||||
PaymentMethod.TRANSFER -> {
|
||||
(this.detail as? BankTransferPaymentDetail)
|
||||
?.toEntity(id, paymentId, suppliedAmount, vat)
|
||||
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
}
|
||||
|
||||
PaymentMethod.EASY_PAY -> {
|
||||
when (this.detail) {
|
||||
is EasypayCardPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
|
||||
is EasypayPrepaidPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
|
||||
|
||||
else -> {
|
||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PaymentMethod.CARD -> {
|
||||
(this.detail as? CardPaymentDetail)
|
||||
?.toEntity(id, paymentId, suppliedAmount, vat)
|
||||
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
}
|
||||
|
||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package com.sangdol.roomescape.payment.mapper
|
||||
|
||||
import com.sangdol.roomescape.payment.dto.PaymentCancelDetailResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentDetailResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
|
||||
fun PaymentEntity.toResponse(
|
||||
detail: PaymentDetailResponse?,
|
||||
cancel: PaymentCancelDetailResponse?
|
||||
): PaymentResponse {
|
||||
return PaymentResponse(
|
||||
orderId = this.orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
method = this.method.koreanName,
|
||||
status = this.status,
|
||||
requestedAt = this.requestedAt,
|
||||
approvedAt = this.approvedAt,
|
||||
detail = detail,
|
||||
cancel = cancel
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentDetailEntity.toResponse(): PaymentDetailResponse {
|
||||
return when (this) {
|
||||
is PaymentCardDetailEntity -> this.toResponse()
|
||||
is PaymentBankTransferDetailEntity -> this.toResponse()
|
||||
is PaymentEasypayPrepaidDetailEntity -> this.toResponse()
|
||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
}
|
||||
|
||||
fun PaymentCardDetailEntity.toResponse(): PaymentDetailResponse.CardDetailResponse {
|
||||
return PaymentDetailResponse.CardDetailResponse(
|
||||
issuerCode = this.issuerCode.koreanName,
|
||||
cardType = this.cardType.koreanName,
|
||||
ownerType = this.ownerType.koreanName,
|
||||
cardNumber = this.cardNumber,
|
||||
amount = this.amount,
|
||||
approvalNumber = this.approvalNumber,
|
||||
installmentPlanMonths = this.installmentPlanMonths,
|
||||
easypayProviderName = this.easypayProviderCode?.koreanName,
|
||||
easypayDiscountAmount = this.easypayDiscountAmount
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentBankTransferDetailEntity.toResponse(): PaymentDetailResponse.BankTransferDetailResponse {
|
||||
return PaymentDetailResponse.BankTransferDetailResponse(
|
||||
bankName = this.bankCode.koreanName
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentEasypayPrepaidDetailEntity.toResponse(): PaymentDetailResponse.EasyPayPrepaidDetailResponse {
|
||||
return PaymentDetailResponse.EasyPayPrepaidDetailResponse(
|
||||
providerName = this.easypayProviderCode.koreanName,
|
||||
amount = this.amount,
|
||||
discountAmount = this.discountAmount
|
||||
)
|
||||
}
|
||||
|
||||
fun CanceledPaymentEntity.toResponse(): PaymentCancelDetailResponse {
|
||||
return PaymentCancelDetailResponse(
|
||||
cancellationRequestedAt = this.requestedAt,
|
||||
cancellationApprovedAt = this.canceledAt,
|
||||
cancelReason = this.cancelReason,
|
||||
canceledBy = this.canceledBy
|
||||
)
|
||||
}
|
||||
@ -5,26 +5,19 @@ import com.sangdol.roomescape.auth.web.support.User
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import com.sangdol.roomescape.payment.business.PaymentService
|
||||
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/payments")
|
||||
class PaymentController(
|
||||
private val paymentService: PaymentService
|
||||
) : PaymentAPI {
|
||||
|
||||
@PostMapping
|
||||
override fun confirmPayment(
|
||||
@RequestParam(required = true) reservationId: Long,
|
||||
@Valid @RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> {
|
||||
val response = paymentService.confirm(reservationId, request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
override fun cancelPayment(
|
||||
@User user: CurrentUserContext,
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
package com.sangdol.roomescape.payment.web
|
||||
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentConfirmRequest(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val amount: Int,
|
||||
val paymentType: PaymentType
|
||||
)
|
||||
|
||||
data class PaymentCreateResponse(
|
||||
val paymentId: Long,
|
||||
val detailId: Long
|
||||
)
|
||||
|
||||
data class PaymentCancelRequest(
|
||||
val reservationId: Long,
|
||||
val cancelReason: String,
|
||||
val requestedAt: LocalDateTime = LocalDateTime.now()
|
||||
)
|
||||
|
||||
data class PaymentWithDetailResponse(
|
||||
val orderId: String,
|
||||
val totalAmount: Int,
|
||||
val method: String,
|
||||
val status: PaymentStatus,
|
||||
val requestedAt: OffsetDateTime,
|
||||
val approvedAt: OffsetDateTime,
|
||||
val detail: PaymentDetailResponse?,
|
||||
val cancel: PaymentCancelDetailResponse?,
|
||||
)
|
||||
|
||||
fun PaymentEntity.toDetailResponse(
|
||||
detail: PaymentDetailResponse?,
|
||||
cancel: PaymentCancelDetailResponse?
|
||||
): PaymentWithDetailResponse {
|
||||
return PaymentWithDetailResponse(
|
||||
orderId = this.orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
method = this.method.koreanName,
|
||||
status = this.status,
|
||||
requestedAt = this.requestedAt,
|
||||
approvedAt = this.approvedAt,
|
||||
detail = detail,
|
||||
cancel = cancel
|
||||
)
|
||||
}
|
||||
|
||||
sealed class PaymentDetailResponse {
|
||||
|
||||
data class CardDetailResponse(
|
||||
val type: String = "CARD",
|
||||
val issuerCode: String,
|
||||
val cardType: String,
|
||||
val ownerType: String,
|
||||
val cardNumber: String,
|
||||
val amount: Int,
|
||||
val approvalNumber: String,
|
||||
val installmentPlanMonths: Int,
|
||||
val easypayProviderName: String?,
|
||||
val easypayDiscountAmount: Int?,
|
||||
) : PaymentDetailResponse()
|
||||
|
||||
data class BankTransferDetailResponse(
|
||||
val type: String = "BANK_TRANSFER",
|
||||
val bankName: String,
|
||||
) : PaymentDetailResponse()
|
||||
|
||||
data class EasyPayPrepaidDetailResponse(
|
||||
val type: String = "EASYPAY_PREPAID",
|
||||
val providerName: String,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
) : PaymentDetailResponse()
|
||||
}
|
||||
|
||||
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
|
||||
return when (this) {
|
||||
is PaymentCardDetailEntity -> this.toCardDetailResponse()
|
||||
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
|
||||
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
|
||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
}
|
||||
|
||||
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
|
||||
return CardDetailResponse(
|
||||
issuerCode = this.issuerCode.koreanName,
|
||||
cardType = this.cardType.koreanName,
|
||||
ownerType = this.ownerType.koreanName,
|
||||
cardNumber = this.cardNumber,
|
||||
amount = this.amount,
|
||||
approvalNumber = this.approvalNumber,
|
||||
installmentPlanMonths = this.installmentPlanMonths,
|
||||
easypayProviderName = this.easypayProviderCode?.koreanName,
|
||||
easypayDiscountAmount = this.easypayDiscountAmount
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
|
||||
return BankTransferDetailResponse(
|
||||
bankName = this.bankCode.koreanName
|
||||
)
|
||||
}
|
||||
|
||||
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
|
||||
return EasyPayPrepaidDetailResponse(
|
||||
providerName = this.easypayProviderCode.koreanName,
|
||||
amount = this.amount,
|
||||
discountAmount = this.discountAmount
|
||||
)
|
||||
}
|
||||
|
||||
data class PaymentCancelDetailResponse(
|
||||
val cancellationRequestedAt: LocalDateTime,
|
||||
val cancellationApprovedAt: OffsetDateTime?,
|
||||
val cancelReason: String,
|
||||
val canceledBy: Long,
|
||||
)
|
||||
|
||||
fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse {
|
||||
return PaymentCancelDetailResponse(
|
||||
cancellationRequestedAt = this.requestedAt,
|
||||
cancellationApprovedAt = this.canceledAt,
|
||||
cancelReason = this.cancelReason,
|
||||
canceledBy = this.canceledBy
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
package com.sangdol.roomescape.region.business
|
||||
|
||||
import com.sangdol.roomescape.region.dto.*
|
||||
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
||||
import com.sangdol.roomescape.region.exception.RegionException
|
||||
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
||||
import com.sangdol.roomescape.region.exception.RegionException
|
||||
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
||||
import com.sangdol.roomescape.region.web.*
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -17,56 +17,56 @@ class RegionService(
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun readAllSido(): SidoListResponse {
|
||||
log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" }
|
||||
log.debug { "[readAllSido] 모든 시/도 조회 시작" }
|
||||
val result: List<Pair<String, String>> = regionRepository.readAllSido()
|
||||
|
||||
if (result.isEmpty()) {
|
||||
log.warn { "[RegionService.readAllSido] 시/도 조회 실패" }
|
||||
log.warn { "[readAllSido] 시/도 조회 실패" }
|
||||
throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND)
|
||||
}
|
||||
|
||||
return SidoListResponse(result.map { SidoResponse(code = it.first, name = it.second) }).also {
|
||||
log.info { "[RegionService.readAllSido] ${it.sidoList.size}개의 시/도 조회 완료" }
|
||||
log.info { "[readAllSido] ${it.sidoList.size}개의 시/도 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
|
||||
log.info { "[RegionService.findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
||||
log.debug { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
||||
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
|
||||
|
||||
if (result.isEmpty()) {
|
||||
log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
|
||||
log.warn { "[findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
|
||||
throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND)
|
||||
}
|
||||
|
||||
return SigunguListResponse(result.map { SigunguResponse(code = it.first, name = it.second) }).also {
|
||||
log.info { "[RegionService.findSigunguBySido] sidoCode=${sidoCode}인 ${it.sigunguList.size}개의 시/군/구 조회 완료" }
|
||||
log.info { "[findSigunguBySido] sidoCode=${sidoCode}인 ${it.sigunguList.size}개의 시/군/구 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
|
||||
log.info { "[RegionService.findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
log.debug { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
|
||||
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
|
||||
log.info { "[RegionService.findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
RegionCodeResponse(it)
|
||||
} ?: run {
|
||||
log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
log.warn { "[findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findRegionInfo(regionCode: String): RegionInfoResponse {
|
||||
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
||||
log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
||||
|
||||
return regionRepository.findByCode(regionCode)?.let {
|
||||
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
||||
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
||||
RegionInfoResponse(it.code, it.sidoName, it.sigunguName)
|
||||
} ?: run {
|
||||
log.warn { "[RegionService.findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
|
||||
log.warn { "[findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
|
||||
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.auth.web.support.Public
|
||||
import com.sangdol.roomescape.region.web.RegionCodeResponse
|
||||
import com.sangdol.roomescape.region.web.SidoListResponse
|
||||
import com.sangdol.roomescape.region.web.SigunguListResponse
|
||||
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.sangdol.roomescape.region.web
|
||||
package com.sangdol.roomescape.region.dto
|
||||
|
||||
data class SidoResponse(
|
||||
val code: String,
|
||||
@ -1,8 +1,8 @@
|
||||
package com.sangdol.roomescape.region.exception
|
||||
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
|
||||
class RegionException(
|
||||
override val errorCode: RegionErrorCode,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user