Compare commits

...

12 Commits

Author SHA1 Message Date
5e572c842c Merge pull request '[#72] 로그 레벨 재조정' (#73) from refactor/#72 into main
Reviewed-on: #73
2025-11-08 06:02:09 +00:00
7a236a8196 chore: 미사용 마크다운 제거 2025-10-21 09:44:34 +09:00
0756e21b63 refactor: 로그 레벨 재조정 2025-10-21 08:05:20 +09:00
162e5bbc79 [#70] 중복 조회 로직에 로컬 캐시 도입 (#71)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #70

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Spring Cache + Caffeine 도입
- 테마 조회 및 수정 / 삭제 로직에 캐시 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- P95 응답 시간 약 14% 개선
- Hikari Pool Connection & Tomcat Threads에서의 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #71
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 10:47:15 +00:00
be2e6c606e [#68] ArgumentResolver에서의 불필요한 DB 요청 로직 제거 (#69)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #68

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- ArgumentResolver에서의 DB 조회 요청 제거

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 성능 테스트 결과 Hikari Pending 커넥션의 최대 개수가 80 -> 5로 대폭 감소
- Tomcat 스레드도 기존은 최대 200개까지 활성화 되었으나, 개선 후 최대 80까지만 처리됨.
- P95 응답 시간 235 -> 141ms로 40% 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #69
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 06:18:50 +00:00
06f7faf7f9 [#66] 결제 & 예약 확정 로직 수정 (#67)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #66

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이전 #64 의 작업 이후, 결제 & 예약 확정 API 로직은 총 3개의 독립된 트랜잭션을 사용함.
- 검증 & 배치 충돌 방지를 위한 첫 번째 트랜잭션 이외의 다른 트랜잭션은 불필요하다고 판단함. -> PG사 성공 응답이 오면 나머지 작업은 \@Async 처리 후 바로 성공 응답

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 변경된 로직에 대한 통합 테스트 완료
- 성능 테스트 결과 P95 응답 시간 327.01ms -> 235.52ms / 평균 응답 시간 77.85 -> 68.16ms /  최대 응답 시간 5.26 -> 4.08초 단축

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #67
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 04:59:12 +00:00
79de5c9c63 [#64] 결제 & 예약 확정 API에서의 트랜잭션 범위 수정 (#65)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #64

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 기존의 결제 시도 이력 테이블 조회 & 검증 -> 예약 / 일정 조회 및 검증을 하나의 트랜잭션으로 통합
- 예약 / 일정 LOCK 조회를 가장 먼저 수행 -> 배치와의 충돌을 방지하기 위함

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 동일 조건에서 테스트했을 때 P95 응답 시간 749 -> 327ms로 50% 가량 개선 확인
- 커넥션 대기로 길어진 최대 API 응답 시간 7.70 -> 2.88초로 대폭 감소

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #65
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-15 02:24:18 +00:00
5f2e44bb11 [#60] Trace Context의 초기화 오류로 발생하는 OOM 문제 해결 (#63)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #60

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- JVM Heapdump 분석 결과 Opentelemetry의 Context가 과도한 메모리를 사용하고 있었음.(Retain 925MB / 파드 메모리 할당 Limit 2GB 중)
- 원인은, 하나의 요청이 끝날 때 Trace Context가 초기화 되지 않았고, 동일한 Tomcat 스레드에 서로 다른 HTTP 요청의 SPAN이 계속 쌓인 것.
- Slow-query 를 기록하기 위한 Datasource-Proxy와 충돌이 그 이유였고, 슬로우쿼리 기록은 필요하기에 CurrentTraceContext를 이용하여 필터의 Finally 과정에서 컨텍스트를 정리하도록 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- JVM의 Old, Survivor Space에서의 메모리 사용량 감소 확인
- 동일한 환경 테스트에서 OOM 발생하지 않음.

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #63
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:38:46 +00:00
bba3266f3f [#61] 커넥션 고갈 해결을 위한 로그인 이력 저장 비동기 처리 (#62)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #61

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이력 저장을 비동기 + Batch Insert로 구현하여 기존의 '로그인 완료 - 이력 저장(동기)' 로직, 특히 이력 저장을 별도의 트랜잭션으로 진행하며 발생하던 커넥션 고갈 문제를 해결
- 이벤트를 수신하면 In-Memory Queue에 저장하게 되어, OOM 발생 가능성이 있다고 판단. => 100개가 넘어가는 순간 바로 Batch Insert를 수행하도록 함.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 로컬 환경에서 Login API만 별도로 성능 테스트 => 기존 로직에서는 70VU에서 다운, 개선 후 1000VU, 초당 558번의 요청에서도 정상 동작
- 테스트 결과 메모리 사용량의 큰 변화 없이 커넥션 고갈 문제 해결

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #62
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:28:44 +00:00
135b13a9bf [#58] K6 성능 테스트 도입 (#59)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #58

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- K6 성능 테스트 스크립트 추가 및 배포 환경에서의 정상 동작 확인
- 정상 동작 과정 확인 중 발견된 slow-query 개선 => 커버링 인덱스를 생각했으나, 실제로 사용하지 않고 테이블 풀스캔을 하던 문제

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 스크립트는 크게 사용자가 예약할 수 있는 일정을 만드는 작업과 사용자가 예약하는 작업 두 가지로 구분
- 후자의 테스트는 40VU까지는 여유있게 처리 확인 => 다음 과정부터는 부하를 더 높여 진행할 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #59
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-11 07:38:26 +00:00
047e4a395b [#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 (#57)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #56

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
<img width="1163" alt="스크린샷 2025-10-09 18.26.43.png" src="attachments/b1651431-c1c4-4198-84c8-2019bde70dd6">
- '결제 요청 API가 호출된 이상 사용자는 결제를 시도한 것으로 간주한다' + 'PG사 정상 응답만 오면 사용자는 결제를 성공한 것이다' 의 관점으로 구현
- 결제 요청 API가 호출되면, 이미 예약이 완료, 취소, 만료된 것이 아니라면 검증 후 해당 예약을 배치가 처리하지 못하게 PAYMENT_IN_PROGRESS로 변경
- PG사 결제가 성공하면, 이후의 결제 & 예약 정보 저장의 성공 여부와 무관하게 일단 API는 성공 응답 전송
- 매 결제 시도는 성공 / 실패 여부와 무관하게 이력 저장 => 결제 시도 횟수가 N번 이상이면 프론트엔드에서 특정 처리(=> 현장결제 페이지 안내 예정. 현재 바로는 구현 계획 X)
- 기존 배치와의 경합 + 데드락 방지를 위해 배치 작업을 조회 -> 수정 두 단계로 변경했고, 조회 단계에서는 SKIP LOCKED 사용.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 새로 통합한 Order 관련 API 테스트 및 기존 배치와의 경합 상황 테스트

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #57
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-09 09:33:29 +00:00
8215492eea [#54] 애플리케이션 배포 (#55)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #54

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 애플리케이션 배포
- 1차 배포에서 각 Service의 Trace가 구분이 되지 않아 XxxService 클래스에 \@Observation을 적용하는 AOP 추가
- 불필요하게 느껴지는 Prometheus Actuator 요청과 스케쥴링 작업 Tracing 제외
- 애플리케이션이 UTC로 배포됨에 따라 발생하는 문제 해결을 위해 LocalDateTime, OffsetDateTime -> Instant 타입 변경 및 LocalDate, LocalTime은 KST로 비교하도록 수정
- 기존 로그의 가독성이 좋지 않아, 로그 메시지가 가장 먼저 보이도록 형식 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 실제 웹에 접속하여 전체적인 기능 점검
- 예약 처리 로직에서 미숙한 부분이 발견되어 다음 작업은 예약 처리 로직 개선 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #55
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-06 02:42:13 +00:00
230 changed files with 5462 additions and 3877 deletions

View File

@ -1,10 +1,9 @@
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
FROM amazoncorretto:17 FROM amazoncorretto:17
WORKDIR /app WORKDIR /app
COPY service/build/libs/service.jar app.jar
EXPOSE 8080 EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -1,3 +0,0 @@
# issue-pr-template
공통으로 사용하게 될 이슈, PR 템플릿 저장소

6
build.sh Executable file
View 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

View File

@ -7,7 +7,6 @@ import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -48,7 +47,10 @@ class AbstractLogMaskingConverterTest : FunSpec({
event.formattedMessage event.formattedMessage
} returns json.format(account, address) } 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()}"
)
} }
} }
} }

View File

@ -8,7 +8,7 @@ import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
) : PersistableBaseEntity(id) { ) : PersistableBaseEntity(id) {
@Column(updatable = false) @Column(updatable = false)
@CreatedDate @CreatedDate
lateinit var createdAt: LocalDateTime lateinit var createdAt: Instant
@Column(updatable = false) @Column(updatable = false)
@CreatedBy @CreatedBy
@ -25,7 +25,7 @@ abstract class AuditingBaseEntity(
@Column @Column
@LastModifiedDate @LastModifiedDate
lateinit var updatedAt: LocalDateTime lateinit var updatedAt: Instant
@Column @Column
@LastModifiedBy @LastModifiedBy

View File

@ -5,11 +5,7 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualUsingFields import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.clearMocks import io.mockk.*
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionDefinition

View File

@ -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()

View File

@ -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)
}
})

View File

@ -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() }
}
}

View File

@ -1,11 +1,8 @@
package com.sangdol.common.web.config package com.sangdol.common.web.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer 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.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Configuration @Configuration
class JacksonConfig { class JacksonConfig {
companion object { 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 = private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm") DateTimeFormatter.ofPattern("HH:mm")
} }
@ -35,9 +26,9 @@ class JacksonConfig {
@Bean @Bean
fun objectMapper(): ObjectMapper = ObjectMapper() fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule()) .registerModule(javaTimeModule())
.registerModule(dateTimeModule())
.registerModule(kotlinModule()) .registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer( .addSerializer(
@ -56,35 +47,4 @@ class JacksonConfig {
LocalTime::class.java, LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER) LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule ) 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))
}
}
} }

View File

@ -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)
}
}
}

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.web.asepct.ControllerLoggingAspect import com.sangdol.common.web.asepct.ControllerLoggingAspect
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
import com.sangdol.common.web.support.log.WebLogMessageConverter import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.micrometer.tracing.CurrentTraceContext
import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@ -17,9 +18,10 @@ class WebLoggingConfig {
@Bean @Bean
@DependsOn(value = ["webLogMessageConverter"]) @DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean( fun filterRegistrationBean(
webLogMessageConverter: WebLogMessageConverter webLogMessageConverter: WebLogMessageConverter,
currentTraceContext: CurrentTraceContext
): FilterRegistrationBean<OncePerRequestFilter> { ): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(webLogMessageConverter) val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
return FilterRegistrationBean<OncePerRequestFilter>(filter) return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 } .apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }

View File

@ -1,6 +1,5 @@
package com.sangdol.common.web.exception 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.CommonErrorCode
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
@ -31,7 +30,7 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
@ -57,7 +56,7 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
@ -75,30 +74,26 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
.body(errorResponse) .body(errorResponse)
} }
private fun logException( private fun convertExceptionLogMessage(
servletRequest: HttpServletRequest, servletRequest: HttpServletRequest,
httpStatus: HttpStatus, httpStatus: HttpStatus,
errorResponse: CommonErrorResponse, errorResponse: CommonErrorResponse,
exception: Exception exception: Exception
) { ): String {
val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
val logMessage = messageConverter.convertToResponseMessage( return messageConverter.convertToErrorResponseMessage(
type = type,
servletRequest = servletRequest, servletRequest = servletRequest,
httpStatusCode = httpStatus.value(), httpStatus = httpStatus,
responseBody = errorResponse, responseBody = errorResponse,
exception = actualException exception = actualException
) )
log.warn { logMessage }
} }
} }

View File

@ -5,6 +5,7 @@ import com.sangdol.common.utils.MdcStartTimeUtil
import com.sangdol.common.web.support.log.WebLogMessageConverter import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.micrometer.tracing.CurrentTraceContext
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
@ -15,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
class HttpRequestLoggingFilter( class HttpRequestLoggingFilter(
private val messageConverter: WebLogMessageConverter private val messageConverter: WebLogMessageConverter,
private val currentTraceContext: CurrentTraceContext
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
@ -32,9 +34,12 @@ class HttpRequestLoggingFilter(
try { try {
filterChain.doFilter(cachedRequest, cachedResponse) filterChain.doFilter(cachedRequest, cachedResponse)
cachedResponse.copyBodyToResponse() cachedResponse.copyBodyToResponse()
} catch (e: Exception) {
throw e
} finally { } finally {
MdcStartTimeUtil.clear() MdcStartTimeUtil.clear()
MdcPrincipalIdUtil.clear() MdcPrincipalIdUtil.clear()
currentTraceContext.maybeScope(null)
} }
} }
} }

View File

@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter( class WebLogMessageConverter(
@ -18,7 +19,10 @@ class WebLogMessageConverter(
return objectMapper.writeValueAsString(payload) 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) val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint() .endpoint()
.principalId() .principalId()
@ -46,4 +50,19 @@ class WebLogMessageConverter(
return objectMapper.writeValueAsString(payload) 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)
}
} }

View File

@ -7,10 +7,7 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class JacksonConfigTest : FunSpec({ class JacksonConfigTest : FunSpec({
@ -55,38 +52,4 @@ class JacksonConfigTest : FunSpec({
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed" }.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\""
}
}
}) })

View File

@ -121,7 +121,10 @@ class WebLogMessageConverterTest : FunSpec({
this["duration_ms"].shouldNotBeNull() this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null 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["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body 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 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
}
}
} }
}) })

View File

@ -8,7 +8,7 @@ services:
environment: environment:
MYSQL_ROOT_PASSWORD: init MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local MYSQL_DATABASE: roomescape_local
TZ: Asia/Seoul TZ: UTC
command: command:
- --character-set-server=utf8mb4 - --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci

View File

@ -1,6 +1,18 @@
node_modules
.git .git
.DS_Store .gitignore
# Node.js
node_modules
npm-debug.log npm-debug.log
dist
# Build output
build build
dist
# Editor/OS specific
.vscode
.idea
.DS_Store
# Environment variables
.env*

View File

@ -1,18 +1,17 @@
# Stage 1: Build the React app FROM node:24-alpine AS builder
FROM node:24 AS builder
WORKDIR /app WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --frozen-lockfile COPY package.json package-lock.json ./
RUN npm ci
COPY . . COPY . .
RUN npm run build 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 --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View 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
);
};

View File

@ -0,0 +1,5 @@
export interface OrderErrorResponse {
code: string;
message: string;
trial: number;
}

View File

@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType;
} }
export interface PaymentCancelRequest { export interface PaymentCancelRequest {

View File

@ -1,5 +1,3 @@
import type { Difficulty } from '@_api/theme/themeTypes';
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = { export const ScheduleStatus = {
@ -40,14 +38,33 @@ export interface AdminScheduleSummaryListResponse {
} }
// Public // 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 { export interface ScheduleWithThemeResponse {
id: string, schedule: ScheduleResponse,
startFrom: string, theme: ScheduleThemeInfo
endAt: string,
themeId: string,
themeName: string,
themeDifficulty: Difficulty,
status: ScheduleStatus
} }
export interface ScheduleWithThemeListResponse { export interface ScheduleWithThemeListResponse {

View File

@ -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;

View File

@ -1,6 +1,7 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI'; import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes'; import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import { type ReservationData } from '@_api/reservation/reservationTypes';
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI'; import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes'; import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import { getStores } from '@_api/store/storeAPI'; import { getStores } from '@_api/store/storeAPI';
@ -10,7 +11,6 @@ import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeType
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {type ReservationData} from '@_api/reservation/reservationTypes';
import { formatDate } from 'src/util/DateTimeFormatter'; import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => { const ReservationStep1Page: React.FC = () => {
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
fetchSchedules(selectedStore.id, dateStr) fetchSchedules(selectedStore.id, dateStr)
.then(res => { .then(res => {
const grouped = res.schedules.reduce((acc, schedule) => { const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName; const key = schedule.theme.name;
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(schedule); acc[key].push(schedule);
return acc; return acc;
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
const handleConfirmReservation = () => { const handleConfirmReservation = () => {
if (!selectedSchedule) return; if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id) holdSchedule(selectedSchedule.schedule.id)
.then(() => { .then(() => {
fetchThemeById(selectedSchedule.themeId).then(res => { fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = { const reservationData: ReservationData = {
scheduleId: selectedSchedule.id, scheduleId: selectedSchedule.schedule.id,
store: { store: {
id: selectedStore!.id, id: selectedStore!.id,
name: selectedStore!.name, name: selectedStore!.name,
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
maxParticipants: res.maxParticipants, maxParticipants: res.maxParticipants,
}, },
date: selectedDate.toLocaleDateString('en-CA'), date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.startFrom, startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.endAt, endAt: selectedSchedule.schedule.endAt,
}; };
navigate('/reservation/form', {state: reservationData}); navigate('/reservation/form', {state: reservationData});
}).catch(handleError); }).catch(handleError);
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
<h3>3. </h3> <h3>3. </h3>
<div className="schedule-list"> <div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? ( {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 key={themeName} className="theme-schedule-group">
<div className="theme-header"> <div className="theme-header">
<h4>{themeName}</h4> <h4>{themeName}</h4>
<button onClick={() => openThemeModal(schedules[0].themeId)} <button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button"> className="theme-detail-button">
</button> </button>
</div> </div>
<div className="time-slots"> <div className="time-slots">
{schedules.map(schedule => ( {scheduleAndTheme.map(schedule => (
<div <div
key={schedule.id} key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
{`${schedule.startFrom} ~ ${schedule.endAt}`} {`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span> <span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div> </div>
))} ))}
</div> </div>
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
<div className="modal-section modal-info-grid"> <div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p> <p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p> <p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.themeName}</span></p> <p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p> <p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button> <button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>

View File

@ -1,8 +1,9 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { confirm } from '@_api/order/orderAPI';
import { confirmPayment } from '@_api/payment/paymentAPI'; import type { OrderErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes'; import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI'; import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import type { AxiosError } from 'axios';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter'; 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 { 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(() => { useEffect(() => {
if (!reservationId) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: totalPrice, amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirm(reservationId, paymentData)
confirmPayment(reservationId, paymentData)
.then(() => {
return confirmReservation(reservationId);
})
.then(() => { .then(() => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/reservation/success', { 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) => { }).catch((error: any) => {
console.error("Payment request error:", error); console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다."); alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
}); });
}; };

View File

@ -10,10 +10,11 @@ import {
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes'; import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
import {getStores} from '@_api/store/storeAPI'; import {getStores} from '@_api/store/storeAPI';
import {type SimpleStoreResponse} from '@_api/store/storeTypes'; 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 {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext'; import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-schedule-page.css'; import '@_css/admin-schedule-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react'; import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
@ -53,8 +54,8 @@ const AdminSchedulePage: React.FC = () => {
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null); const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null); const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false); const [isLoadingThemeDetails] = useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -332,10 +333,10 @@ const AdminSchedulePage: React.FC = () => {
<h4 className="audit-title"> </h4> <h4 className="audit-title"> </h4>
<div className="audit-body"> <div className="audit-body">
<p> <p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()} <strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
</p> </p>
<p> <p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()} <strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
</p> </p>
<p> <p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id}) <strong>:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})

View File

@ -10,6 +10,7 @@ import {
} from '@_api/store/storeTypes'; } from '@_api/store/storeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext'; import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-store-page.css'; import '@_css/admin-store-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react'; import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
@ -297,10 +298,10 @@ const AdminStorePage: React.FC = () => {
:</strong> {detailedStores[store.id].region.code} :</strong> {detailedStores[store.id].region.code}
</p> </p>
<p> <p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()} <strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
</p> </p>
<p> <p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()} <strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
</p> </p>
<p> <p>
<strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id}) <strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})

View File

@ -10,6 +10,7 @@ import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate, useParams} from 'react-router-dom'; import {useLocation, useNavigate, useParams} from 'react-router-dom';
import '@_css/admin-theme-edit-page.css'; 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 { interface ThemeFormData {
name: string; name: string;
@ -256,8 +257,8 @@ const AdminThemeEditPage: React.FC = () => {
<div className="audit-info"> <div className="audit-info">
<h4 className="audit-title"> </h4> <h4 className="audit-title"> </h4>
<div className="audit-body"> <div className="audit-body">
<p><strong>:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p> <p><strong>:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
<p><strong>:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p> <p><strong>:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
<p><strong>:</strong> {auditInfo.createdBy.name}</p> <p><strong>:</strong> {auditInfo.createdBy.name}</p>
<p><strong>:</strong> {auditInfo.updatedBy.name}</p> <p><strong>:</strong> {auditInfo.updatedBy.name}</p>
</div> </div>

View File

@ -19,7 +19,6 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,

845
query.md
View File

@ -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 = ?;
```

View File

@ -8,6 +8,10 @@ dependencies {
// API docs // API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") 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 // DB
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.mysql:mysql-connector-j")

View File

@ -3,13 +3,23 @@ package com.sangdol.roomescape
import org.springframework.boot.Banner import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication 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( @SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"] scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
) )
class RoomescapeApplication class RoomescapeApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {
System.setProperty("user.timezone", "UTC")
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
val springApplication = SpringApplication(RoomescapeApplication::class.java) val springApplication = SpringApplication(RoomescapeApplication::class.java)
springApplication.setBannerMode(Banner.Mode.OFF) springApplication.setBannerMode(Banner.Mode.OFF)
springApplication.run() springApplication.run()

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.admin.business package com.sangdol.roomescape.admin.business
import com.sangdol.roomescape.common.types.Auditor import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials import com.sangdol.roomescape.admin.mapper.toCredentials
import com.sangdol.roomescape.admin.business.dto.toCredentials
import com.sangdol.roomescape.admin.exception.AdminErrorCode import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.admin.exception.AdminException import com.sangdol.roomescape.admin.exception.AdminException
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository 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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@ -20,29 +20,29 @@ class AdminService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials { fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 시작: account=${account}" } log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account) return adminRepository.findByAccount(account)
?.let { ?.let {
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" } log.info { "[findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
it.toCredentials() it.toCredentials()
} }
?: run { ?: run {
log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 실패: account=${account}" } log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findOperatorOrUnknown(id: Long): Auditor { fun findOperatorOrUnknown(id: Long): Auditor {
log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)?.let { admin -> return adminRepository.findByIdOrNull(id)?.let { admin ->
Auditor(admin.id, admin.name).also { 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 { } ?: run {
log.warn { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" } log.warn { "[findOperatorById] 작업자 정보 조회 실패. id=${id}" }
Auditor.UNKNOWN Auditor.UNKNOWN
} }
} }

View File

@ -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.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.web.LoginCredentials import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.web.LoginSuccessResponse import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
data class AdminLoginCredentials( data class AdminLoginCredentials(
override val id: Long, override val id: Long,
@ -22,15 +21,6 @@ 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( data class AdminLoginSuccessResponse(
override val accessToken: String, override val accessToken: String,
override val name: String, override val name: String,

View File

@ -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
)

View File

@ -1,15 +1,20 @@
package com.sangdol.roomescape.auth.business package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService 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.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.*
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -21,39 +26,40 @@ const val CLAIM_STORE_ID_KEY = "store_id"
class AuthService( class AuthService(
private val adminService: AdminService, private val adminService: AdminService,
private val userService: UserService, private val userService: UserService,
private val loginHistoryService: LoginHistoryService,
private val jwtUtils: JwtUtils, private val jwtUtils: JwtUtils,
private val eventPublisher: ApplicationEventPublisher
) { ) {
@Transactional(readOnly = true)
fun login( fun login(
request: LoginRequest, request: LoginRequest,
context: LoginContext context: LoginContext
): LoginSuccessResponse { ): 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 (credentials, extraClaims) = getCredentials(request)
val event = LoginHistoryEvent(
id = credentials.id,
type = request.principalType,
ipAddress = context.ipAddress,
userAgent = context.userAgent
)
try { try {
verifyPasswordOrThrow(request, credentials) verifyPasswordOrThrow(request, credentials)
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) 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 { 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) { } catch (e: Exception) {
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) eventPublisher.publishEvent(event.onFailure())
when (e) { when (e) {
is AuthException -> { is AuthException -> { throw e }
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" }
throw e
}
else -> { 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) throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
} }
} }
@ -65,7 +71,7 @@ class AuthService(
credentials: LoginCredentials credentials: LoginCredentials
) { ) {
if (credentials.password != request.password) { if (credentials.password != request.password) {
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED) throw AuthException(AuthErrorCode.LOGIN_FAILED)
} }
} }

View File

@ -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}" }
}
}
}

View File

@ -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}" }
}
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,5 @@
package com.sangdol.roomescape.auth.business.domain
enum class PrincipalType {
USER, ADMIN
}

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.auth.docs package com.sangdol.roomescape.auth.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.web.LoginRequest import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User 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.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses

View File

@ -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 com.sangdol.roomescape.auth.business.domain.PrincipalType
import jakarta.servlet.http.HttpServletRequest
enum class PrincipalType {
USER, ADMIN
}
data class LoginContext( data class LoginContext(
val ipAddress: String, val ipAddress: String,
val userAgent: String, val userAgent: String,
) )
fun HttpServletRequest.toLoginContext() = LoginContext(
ipAddress = this.remoteAddr,
userAgent = this.getHeader("User-Agent")
)
data class LoginRequest( data class LoginRequest(
val account: String, val account: String,
val password: String, val password: String,

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.auth.exception 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.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class AuthErrorCode( enum class AuthErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -1,5 +1,7 @@
package com.sangdol.roomescape.auth.infrastructure.jwt 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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.jsonwebtoken.Claims import io.jsonwebtoken.Claims
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
@ -50,7 +50,7 @@ class JwtUtils(
val claims = extractAllClaims(token) val claims = extractAllClaims(token)
return claims.subject ?: run { return claims.subject ?: run {
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" } log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN) throw AuthException(AuthErrorCode.INVALID_TOKEN)
} }
} }

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.auth.infrastructure.persistence package com.sangdol.roomescape.auth.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity 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 jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
@Entity @Entity
@Table(name = "login_history") @Table(name = "login_history")
@ -24,5 +24,5 @@ class LoginHistoryEntity(
@Column(updatable = false) @Column(updatable = false)
@CreatedDate @CreatedDate
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
) : PersistableBaseEntity(id) ) : PersistableBaseEntity(id)

View File

@ -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
)

View File

@ -3,6 +3,9 @@ package com.sangdol.roomescape.auth.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.business.AuthService import com.sangdol.roomescape.auth.business.AuthService
import com.sangdol.roomescape.auth.docs.AuthAPI 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.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -36,3 +39,8 @@ class AuthController(
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }
} }
fun HttpServletRequest.toLoginContext() = LoginContext(
ipAddress = this.remoteAddr,
userAgent = this.getHeader("User-Agent")
)

View File

@ -1,12 +1,6 @@
package com.sangdol.roomescape.auth.web.support.interceptors package com.sangdol.roomescape.auth.web.support.interceptors
import io.github.oshai.kotlinlogging.KLogger import com.sangdol.common.utils.MdcPrincipalIdUtil
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.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege 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.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.accessToken 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 {} private val log: KLogger = KotlinLogging.logger {}
@ -47,7 +47,10 @@ class AdminInterceptor(
return true return true
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is AuthException -> { throw e } is AuthException -> {
throw e
}
else -> { else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" } log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,12 @@
package com.sangdol.roomescape.auth.web.support.interceptors 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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -7,13 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor 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 {} private val log: KLogger = KotlinLogging.logger {}
@ -47,7 +47,10 @@ class UserInterceptor(
return true return true
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is AuthException -> { throw e } is AuthException -> {
throw e
}
else -> { else -> {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" } log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,12 @@
package com.sangdol.roomescape.auth.web.support.resolver 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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest 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.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer 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 {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class UserContextResolver( class UserContextResolver(
private val jwtUtils: JwtUtils, private val jwtUtils: JwtUtils,
private val userService: UserService,
) : HandlerMethodArgumentResolver { ) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean { override fun supportsParameter(parameter: MethodParameter): Boolean {
@ -38,9 +38,11 @@ class UserContextResolver(
val token: String? = request.accessToken() val token: String? = request.accessToken()
try { 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) { } catch (e: Exception) {
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" } log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)

View File

@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
@Profile("local") @Profile("!deploy & local")
class LocalDatabaseCleaner( class LocalDatabaseCleaner(
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate
) { ) {

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.common.config package com.sangdol.roomescape.common.config
import com.sangdol.common.web.config.JacksonConfig
import com.sangdol.common.log.message.AbstractLogMaskingConverter 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"), sensitiveKeys = setOf("password", "accessToken", "phone"),

View File

@ -9,11 +9,9 @@ import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Profile
import javax.sql.DataSource import javax.sql.DataSource
@Configuration @Configuration
@Profile("deploy")
@EnableConfigurationProperties(SlowQueryProperties::class) @EnableConfigurationProperties(SlowQueryProperties::class)
class ProxyDataSourceConfig { class ProxyDataSourceConfig {
@ -36,7 +34,6 @@ class ProxyDataSourceConfig {
.build() .build()
} }
@Profile("deploy")
@ConfigurationProperties(prefix = "slow-query") @ConfigurationProperties(prefix = "slow-query")
data class SlowQueryProperties( data class SlowQueryProperties(
val loggerName: String, val loggerName: String,

View File

@ -1,7 +1,6 @@
package com.sangdol.roomescape.common.config package com.sangdol.roomescape.common.config
import io.swagger.v3.oas.models.OpenAPI 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.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration

View File

@ -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)
}
}
}

View File

@ -1,12 +1,12 @@
package com.sangdol.roomescape.common.config 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.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 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 @Configuration
class WebMvcConfig( class WebMvcConfig(

View File

@ -1,6 +1,6 @@
package com.sangdol.roomescape.common.types package com.sangdol.roomescape.common.types
import java.time.LocalDateTime import java.time.Instant
data class Auditor( data class Auditor(
val id: Long, val id: Long,
@ -12,8 +12,8 @@ data class Auditor(
} }
data class AuditingInfo( data class AuditingInfo(
val createdAt: LocalDateTime, val createdAt: Instant,
val createdBy: Auditor, val createdBy: Auditor,
val updatedAt: LocalDateTime, val updatedAt: Instant,
val updatedBy: Auditor, val updatedBy: Auditor,
) )

View File

@ -1,6 +1,5 @@
package com.sangdol.roomescape.common.types package com.sangdol.roomescape.common.types
data class CurrentUserContext( data class CurrentUserContext(
val id: Long, val id: Long
val name: String,
) )

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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>>
}

View File

@ -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", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
;
}

View File

@ -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)

View File

@ -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())
}
}

View File

@ -1,131 +1,153 @@
package com.sangdol.roomescape.payment.business package com.sangdol.roomescape.payment.business
import io.github.oshai.kotlinlogging.KLogger import com.sangdol.common.persistence.IDGenerator
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import com.sangdol.common.persistence.TransactionExecutionUtil 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.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException 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.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.* 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 {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class PaymentService( class PaymentService(
private val idGenerator: IDGenerator,
private val paymentClient: TosspayClient, private val paymentClient: TosspayClient,
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository, private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository, private val canceledPaymentRepository: CanceledPaymentRepository,
private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil, private val transactionExecutionUtil: TransactionExecutionUtil,
private val eventPublisher: ApplicationEventPublisher
) { ) {
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse { fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm( log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
paymentKey = request.paymentKey, try {
orderId = request.orderId, return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
amount = request.amount, 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 message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
val payment: PaymentEntity = paymentWriter.createPayment( "${errorCode.message}(${e.message})"
reservationId = reservationId, } else {
orderId = request.orderId, errorCode.message
paymentType = request.paymentType, }
paymentClientConfirmResponse = clientConfirmResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) throw PaymentException(errorCode, message)
} ?: run { }
log.warn { "[PaymentService.confirm] 결제 확정 중 예상치 못한 null 반환" } else -> {
log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
} }
} }
}
}
fun cancel(userId: Long, request: PaymentCancelRequest) { fun cancel(userId: Long, request: PaymentCancelRequest) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel( val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
paymentKey = payment.paymentKey, paymentKey = payment.paymentKey,
amount = payment.totalAmount, amount = payment.totalAmount,
cancelReason = request.cancelReason cancelReason = request.cancelReason
) )
transactionExecutionUtil.withNewTransaction(isReadOnly = false) { transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.cancel( val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
userId = userId,
payment = payment, clientCancelResponse.cancels.toEntity(
requestedAt = request.requestedAt, id = idGenerator.create(),
cancelResponse = clientCancelResponse paymentId = payment.id,
) cancelRequestedAt = request.requestedAt,
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}.also { }.also {
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" } log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? { fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.info { "[PaymentService.findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" } log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId) val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) } val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) } val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
return payment?.toDetailResponse( return payment?.toResponse(
detail = paymentDetail?.toPaymentDetailResponse(), detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toCancelDetailResponse() cancel = cancelDetail?.toResponse()
) ).also {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
}
} }
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity { private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(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 { ?: run {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" } log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
} }
} }
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? { private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId) return paymentRepository.findByReservationId(reservationId)
.also { .also {
if (it != null) { if (it != null) {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
} else { } else {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" } log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
} }
} }
} }
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? { private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" } log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also { return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) { if (it != null) {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" } log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
} else { } else {
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" } log.warn { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
} }
} }
} }
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? { private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" } log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also { return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) { if (it == null) {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" } log.info { "[findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
} else { } else {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" } log.info { "[findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" }
} }
} }
} }

View File

@ -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}" }
}
}
}

View File

@ -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 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.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException 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 {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -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()

View File

@ -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 }
}
}
}

View File

@ -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
)

View File

@ -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}" }
}
}

View File

@ -2,29 +2,17 @@ package com.sangdol.roomescape.payment.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User 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.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.web.PaymentCancelRequest import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
interface PaymentAPI { 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 = "결제 취소") @Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment( fun cancelPayment(

View File

@ -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
)

View File

@ -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,
)

View File

@ -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()
)

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.payment.exception 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.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class PaymentErrorCode( enum class PaymentErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -6,3 +6,9 @@ class PaymentException(
override val errorCode: PaymentErrorCode, override val errorCode: PaymentErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)
class ExternalPaymentException(
val httpStatusCode: Int,
val errorCode: String,
override val message: String
) : RuntimeException(message)

View File

@ -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)
}
}

View File

@ -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()
)
}
}

View File

@ -1,6 +1,9 @@
package com.sangdol.roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper 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.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
@ -28,9 +31,9 @@ class TosspayClient(
paymentKey: String, paymentKey: String,
orderId: String, orderId: String,
amount: Int, amount: Int,
): PaymentClientConfirmResponse { ): PaymentGatewayResponse {
val startTime = System.currentTimeMillis() 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) return confirmClient.request(paymentKey, orderId, amount)
.also { .also {
@ -42,9 +45,9 @@ class TosspayClient(
paymentKey: String, paymentKey: String,
amount: Int, amount: Int,
cancelReason: String cancelReason: String
): PaymentClientCancelResponse { ): PaymentGatewayCancelResponse {
val startTime = System.currentTimeMillis() 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 { return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" } 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) 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() val response = client.post()
.uri(CONFIRM_URI) .uri(CONFIRM_URI)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@ -83,7 +86,7 @@ private class ConfirmClient(
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" } 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, paymentKey: String,
amount: Int, amount: Int,
cancelReason: String cancelReason: String
): PaymentClientCancelResponse { ): PaymentGatewayCancelResponse {
val response = client.post() val response = client.post()
.uri(CANCEL_URI, paymentKey) .uri(CANCEL_URI, paymentKey)
.body( .body(
@ -119,7 +122,7 @@ private class CancelClient(
} }
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" } 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 response: ClientHttpResponse
): Nothing { ): Nothing {
val requestType: String = paymentRequestType(url) 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 { private fun paymentRequestType(url: URI): String {

View File

@ -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
)
}

View File

@ -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()
)
}
}

View File

@ -3,8 +3,7 @@ package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.Instant
import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "canceled_payment") @Table(name = "canceled_payment")
@ -12,8 +11,8 @@ class CanceledPaymentEntity(
id: Long, id: Long,
val paymentId: Long, val paymentId: Long,
val requestedAt: LocalDateTime, val requestedAt: Instant,
val canceledAt: OffsetDateTime, val canceledAt: Instant,
val canceledBy: Long, val canceledBy: Long,
val cancelReason: String, val cancelReason: String,
val cancelAmount: Int, val cancelAmount: Int,

View File

@ -1,7 +1,11 @@
package com.sangdol.roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity 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.* import jakarta.persistence.*
@Entity @Entity

View File

@ -1,14 +1,14 @@
package com.sangdol.roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.business.domain.PaymentType
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.OffsetDateTime import java.time.Instant
@Entity @Entity
@Table(name = "payment") @Table(name = "payment")
@ -19,8 +19,8 @@ class PaymentEntity(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,
val totalAmount: Int, val totalAmount: Int,
val requestedAt: OffsetDateTime, val requestedAt: Instant,
val approvedAt: OffsetDateTime, val approvedAt: Instant,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val type: PaymentType, val type: PaymentType,

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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)
}
}

View File

@ -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
)
}

View File

@ -5,26 +5,19 @@ import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.docs.PaymentAPI import com.sangdol.roomescape.payment.docs.PaymentAPI
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity 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 @RestController
@RequestMapping("/payments") @RequestMapping("/payments")
class PaymentController( class PaymentController(
private val paymentService: PaymentService private val paymentService: PaymentService
) : PaymentAPI { ) : 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") @PostMapping("/cancel")
override fun cancelPayment( override fun cancelPayment(
@User user: CurrentUserContext, @User user: CurrentUserContext,

View File

@ -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
)
}

View File

@ -1,13 +1,13 @@
package com.sangdol.roomescape.region.business 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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import 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 {} private val log: KLogger = KotlinLogging.logger {}
@ -17,56 +17,56 @@ class RegionService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun readAllSido(): SidoListResponse { fun readAllSido(): SidoListResponse {
log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" } log.debug { "[readAllSido] 모든 시/도 조회 시작" }
val result: List<Pair<String, String>> = regionRepository.readAllSido() val result: List<Pair<String, String>> = regionRepository.readAllSido()
if (result.isEmpty()) { if (result.isEmpty()) {
log.warn { "[RegionService.readAllSido] 시/도 조회 실패" } log.warn { "[readAllSido] 시/도 조회 실패" }
throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND) throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND)
} }
return SidoListResponse(result.map { SidoResponse(code = it.first, name = it.second) }).also { 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) @Transactional(readOnly = true)
fun findSigunguBySido(sidoCode: String): SigunguListResponse { 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) val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
if (result.isEmpty()) { if (result.isEmpty()) {
log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" } log.warn { "[findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND) throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND)
} }
return SigunguListResponse(result.map { SigunguResponse(code = it.first, name = it.second) }).also { 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) @Transactional(readOnly = true)
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse { 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 { 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) RegionCodeResponse(it)
} ?: run { } ?: run {
log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } log.warn { "[findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findRegionInfo(regionCode: String): RegionInfoResponse { fun findRegionInfo(regionCode: String): RegionInfoResponse {
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" } log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
return regionRepository.findByCode(regionCode)?.let { 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) RegionInfoResponse(it.code, it.sidoName, it.sigunguName)
} ?: run { } ?: run {
log.warn { "[RegionService.findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" } log.warn { "[findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
} }
} }

View File

@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.region.web.RegionCodeResponse import com.sangdol.roomescape.region.dto.RegionCodeResponse
import com.sangdol.roomescape.region.web.SidoListResponse import com.sangdol.roomescape.region.dto.SidoListResponse
import com.sangdol.roomescape.region.web.SigunguListResponse import com.sangdol.roomescape.region.dto.SigunguListResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.region.web package com.sangdol.roomescape.region.dto
data class SidoResponse( data class SidoResponse(
val code: String, val code: String,

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.region.exception 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.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class RegionException( class RegionException(
override val errorCode: RegionErrorCode, override val errorCode: RegionErrorCode,

View File

@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.region.business.RegionService import com.sangdol.roomescape.region.business.RegionService
import com.sangdol.roomescape.region.docs.RegionAPI import com.sangdol.roomescape.region.docs.RegionAPI
import com.sangdol.roomescape.region.dto.RegionCodeResponse
import com.sangdol.roomescape.region.dto.SidoListResponse
import com.sangdol.roomescape.region.dto.SigunguListResponse
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping

View File

@ -3,23 +3,28 @@ package com.sangdol.roomescape.reservation.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse import com.sangdol.roomescape.payment.dto.PaymentResponse
import com.sangdol.roomescape.reservation.dto.*
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.infrastructure.persistence.* import com.sangdol.roomescape.reservation.infrastructure.persistence.*
import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse
import com.sangdol.roomescape.reservation.mapper.toEntity
import com.sangdol.roomescape.reservation.mapper.toOverviewResponse
import com.sangdol.roomescape.reservation.mapper.toStateResponse
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
import com.sangdol.roomescape.user.web.UserContactResponse import com.sangdol.roomescape.user.dto.UserContactResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.Instant
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -40,19 +45,26 @@ class ReservationService(
user: CurrentUserContext, user: CurrentUserContext,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
): PendingReservationCreateResponse { ): PendingReservationCreateResponse {
log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } log.debug { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
validateCanCreate(request) run {
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id) reservationValidator.validateCanCreate(schedule, theme, request)
}
return PendingReservationCreateResponse(reservationRepository.save(reservation).id) val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also {
.also { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } reservationRepository.save(it)
}
return PendingReservationCreateResponse(reservation.id)
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
} }
@Transactional @Transactional
fun confirmReservation(id: Long) { fun confirmReservation(id: Long) {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 시작: reservationId=${id}" } log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
run { run {
@ -63,13 +75,13 @@ class ReservationService(
changeStatus = ScheduleStatus.RESERVED changeStatus = ScheduleStatus.RESERVED
) )
}.also { }.also {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" } log.info { "[confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
} }
} }
@Transactional @Transactional
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) { fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
log.info { "[ReservationService.cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" } log.debug { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId) val reservation: ReservationEntity = findOrThrow(reservationId)
@ -82,13 +94,13 @@ class ReservationService(
saveCanceledReservation(user, reservation, request.cancelReason) saveCanceledReservation(user, reservation, request.cancelReason)
reservation.cancel() reservation.cancel()
}.also { }.also {
log.info { "[ReservationService.cancelReservation] 예약 취소 완료: reservationId=${reservationId}" } log.info { "[cancelReservation] 예약 취소 완료: reservationId=${reservationId}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse { fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" } log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn( val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
userId = user.id, userId = user.id,
@ -96,36 +108,68 @@ class ReservationService(
) )
return ReservationOverviewListResponse(reservations.map { return ReservationOverviewListResponse(reservations.map {
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId) val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId)
it.toOverviewResponse(schedule) val schedule = response.schedule
it.toOverviewResponse(
scheduleDate = schedule.date,
scheduleStartFrom = schedule.startFrom,
scheduleEndAt = schedule.endAt,
storeName = response.theme.name,
themeName = response.store.name
)
}).also { }).also {
log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationDetailResponse { fun findDetailById(id: Long): ReservationAdditionalResponse {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 시작: reservationId=${id}" } log.debug { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId) val user: UserContactResponse = userService.findContactById(reservation.userId)
val paymentDetail: PaymentWithDetailResponse? = paymentService.findDetailByReservationId(id) val paymentDetail: PaymentResponse? = paymentService.findDetailByReservationId(id)
return reservation.toReservationDetailRetrieveResponse( return reservation.toAdditionalResponse(
user = user, user = user,
payment = paymentDetail payment = paymentDetail
).also { ).also {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 완료: reservationId=${id}" } log.info { "[findDetailById] 예약 상세 조회 완료: reservationId=${id}" }
}
}
@Transactional(readOnly = true)
fun findStatusWithLock(id: Long): ReservationStateResponse {
log.debug { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdForUpdate(id)?.let {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
it.toStateResponse()
} ?: run {
log.warn { "[findStatusWithLock] 예약 LOCK + 상태 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
@Transactional
fun markInProgress(reservationId: Long) {
log.debug { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
findOrThrow(reservationId).apply {
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
}.also {
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 완료" }
} }
} }
private fun findOrThrow(id: Long): ReservationEntity { private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" } log.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id) return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[ReservationService.findOrThrow] 예약 조회 완료: reservationId=${id}" } } ?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
?: run { ?: run {
log.warn { "[ReservationService.findOrThrow] 예약 조회 실패: reservationId=${id}" } log.warn { "[findOrThrow] 예약 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
} }
} }
@ -136,7 +180,7 @@ class ReservationService(
cancelReason: String cancelReason: String
) { ) {
if (reservation.userId != user.id) { if (reservation.userId != user.id) {
log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" } log.warn { "[createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION) throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
} }
@ -145,18 +189,10 @@ class ReservationService(
reservationId = reservation.id, reservationId = reservation.id,
canceledBy = user.id, canceledBy = user.id,
cancelReason = cancelReason, cancelReason = cancelReason,
canceledAt = LocalDateTime.now(), canceledAt = Instant.now(),
status = CanceledReservationStatus.COMPLETED status = CanceledReservationStatus.COMPLETED
).also { ).also {
canceledReservationRepository.save(it) canceledReservationRepository.save(it)
} }
} }
private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request)
}
} }

View File

@ -1,14 +1,16 @@
package com.sangdol.roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import java.time.LocalDateTime
import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse
import com.sangdol.roomescape.theme.web.ThemeInfoResponse
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -16,22 +18,35 @@ private val log: KLogger = KotlinLogging.logger {}
class ReservationValidator { class ReservationValidator {
fun validateCanCreate( fun validateCanCreate(
schedule: ScheduleSummaryResponse, schedule: ScheduleStateResponse,
theme: ThemeInfoResponse, theme: ThemeInfoResponse,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
) { ) {
validateSchedule(schedule)
validateReservationInfo(theme, request)
}
private fun validateSchedule(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) { if (schedule.status != ScheduleStatus.HOLD) {
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
} }
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) {
log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" }
throw ReservationException(ReservationErrorCode.PAST_SCHEDULE)
}
}
private fun validateReservationInfo(theme: ThemeInfoResponse, request: PendingReservationCreateRequest) {
if (theme.minParticipants > request.participantCount) { if (theme.minParticipants > request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
} }
if (theme.maxParticipants < request.participantCount) { if (theme.maxParticipants < request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } log.info { "[validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
} }
} }

Some files were not shown because too many files have changed in this diff Show More