Compare commits

..

116 Commits

Author SHA1 Message Date
cd3ff32f9f refactor: H2 - MySQL 스키마 통일 2025-09-19 19:17:33 +09:00
f92c82a382 fix: 모든 시/도 코드 조회시 MySQL에서 발생하는 ONLY_FULL_GROUP_BY 문제 해결 2025-09-19 19:17:16 +09:00
c2b50c4dd7 fix: 프론트엔드 예약 API에서의 파라미터 오류 수정 2025-09-19 18:49:04 +09:00
2d08a20aff test: Schedule 및 Reservation에서의 변경 사항 테스트 반영 2025-09-19 18:39:51 +09:00
c0df43f840 refactor: 회원 예약 페이지 엔드포인트 수정("/summary" -> "/overview") 2025-09-19 18:36:00 +09:00
1bd2292ea0 refactor: 회원 예약 조회에서의 변경된 스펙 프론트엔드 반영 2025-09-19 18:31:10 +09:00
c64e613a2b refactor: 회원의 예약 상세 조회 정보에 예약자 정보 반영 2025-09-19 18:03:56 +09:00
70b9c58c15 refactor: ReservationSummaryResponse -> ReservationOverviewResponse 클래스명 수정 및 schedule에 추가된 ScheduleOverviewResponse 조회 적용 2025-09-19 18:01:01 +09:00
5a6f7c4763 feat: ScheduleId를 이용한 ScheduleOverview 단건 조회 기능 추가 2025-09-19 18:00:09 +09:00
86aa4c3046 refactor: schedule에서 사용하는 도메인에 매장 필드 추가 및 클래스명 수정(ScheduleWithTheme -> ScheduleOverview) 2025-09-19 17:48:25 +09:00
8c6222237b chore: Optimize Imports 2025-09-18 20:41:13 +09:00
232e7882de refactor: 미사용 코드 제거 2025-09-18 20:39:42 +09:00
0b8963b8c5 test: \@User 어노테이션 처리 방법 변경으로 인해 실패하는 테스트 수정 2025-09-18 20:31:35 +09:00
bea544d0fc test: 매장 API 테스트 추가 2025-09-18 20:28:21 +09:00
6a7e1906d2 refactor: 중복된 \@Public 어노테이션 제거 2025-09-18 20:28:11 +09:00
ed8b48712e test: \@User 어노테이션 처리시 토큰 조회 오류 테스트 케이스 추가 2025-09-18 20:27:59 +09:00
42f0c8bcd3 feat: 테스트용 StoreRegisterRequest Fixture 추가 2025-09-18 20:27:32 +09:00
c7c8e9ddcf feat: 매장 CRUD 관련 Validation 추가 2025-09-18 20:27:27 +09:00
910b19c83a refactor: schedule 스키마에 store_id FK 지정 2025-09-18 20:25:32 +09:00
8a9ec2e216 refactor: 매장 정보 수정 DTO의 null 기본값 지정 2025-09-18 20:25:21 +09:00
08af1c7084 refactor: API 인터페이스 간결화 및 일부 오타 수정 2025-09-18 20:24:39 +09:00
5f546f87da refactor: 미사용 코드 제거 2025-09-18 20:04:01 +09:00
8321356051 test: 테스트에서의 변경사항 PaymentAPITest 반영 2025-09-18 16:25:49 +09:00
be18775271 test: 테스트에서의 변경사항 ReservationApiTest 반영 2025-09-18 16:23:39 +09:00
45813fc04d refactor: TestAuthUtil에서 회원 로그인 시에도 Pair<UserEntity, Token> 반환 수정 및 반영 2025-09-18 16:23:26 +09:00
48b4a7597f refactor: DummyInitializer의 예약 생성에도 storeId 추가 2025-09-18 16:22:39 +09:00
58f7297c48 test: AuthApiTest에서의 일부 오타 수정 2025-09-18 15:44:49 +09:00
638df9f110 test: UserApiTest에 AuthUtil에서의 반환타입 변경사항 반영 2025-09-18 15:42:47 +09:00
4a30cc8c14 feat: 관리자 생성시 type, storeId 충돌 해결 및 반환 타입 수정 2025-09-18 15:42:15 +09:00
522d64cc8a feat: RestAssured 응답을 객체로 변환하는 유틸 추가 2025-09-18 15:41:24 +09:00
f62ac181ee refactor: 테마 API 테스트에 DummyInitializer에서의 변경사항 반영 2025-09-18 15:40:57 +09:00
4331acee31 refactor: 일정 API 테스트 수정 및 관리자 / 비 관리자로 분리 2025-09-18 15:30:45 +09:00
7cfc7a4d9f refactor: schedule 스키마의 기존 unique 제약조건에 storeId 추가 2025-09-18 15:30:09 +09:00
6b0c9709ed refactor: Servlet Filter에서의 MDC 제거 로직 변경 2025-09-18 15:29:39 +09:00
efb7148215 refactor: Fixture에서 발생하는 Unique 제약 조건 해결 및 일부 엔티티 생성 메서드 추가 2025-09-18 15:29:07 +09:00
75f628c991 refactor: dummyinitializer 로직을 Api 호출 -> repository 사용으로 수정 2025-09-18 15:28:15 +09:00
2f8c2a6a55 refactor: audit 확인 서비스 메서드명 수정 2025-09-18 15:27:25 +09:00
89ada4b146 refactor: schedule에서의 존재 조건 확인을 위한 storeId 필드 추가 2025-09-18 15:27:01 +09:00
54648a6c04 fix: equals 판단 실패 해결을 위한ScheduleFactory 내 나노초 제거 추가 2025-09-18 15:26:41 +09:00
b8cf1d6c9d feat: 테스트에 새로 추가된 Store 기능 도입 및 테마 API 테스트 2025-09-17 13:00:54 +09:00
dc37ae6d1a feat: 지금까지 구현된 매장 등 정보를 반영한 프론트엔드 기능 구현 완료 2025-09-17 10:43:22 +09:00
7a6afc7282 feat: 다른 서비스에서 사용할 지역 정보 조회 서비스 로직 추가 2025-09-17 10:42:52 +09:00
7c967defcc refactor: \@AdminOnly의 기본 타입을 STORE -> ALL로 수정 및 필드 오타 수정 2025-09-17 10:42:24 +09:00
7fd278aa43 feat: 매장 관련 CRUD API 추가 2025-09-17 10:42:11 +09:00
0ef47b7f94 refactor: 테마 DTO 및 컨트롤러 권한변 클래스 분리 2025-09-17 10:39:22 +09:00
cf65ccf915 refactor: 입력된 Id 리스트로 테마 조회시 In 쿼리로 변경 2025-09-17 10:37:47 +09:00
cb9125ef1d refactor: ScheduleController 관리자 / 비 관리자 분리 2025-09-17 10:36:38 +09:00
4aacaddcfc refactor: 일정 처리 로직 수정 및 권한별 주석 구분 추가 2025-09-17 10:36:07 +09:00
c7f98c3515 feat: 일정 DTO를 관리자 / 비 관리자로 구분 2025-09-17 10:35:01 +09:00
eec279c76f feat: Repository에 ScheduleWithThemeSummary 조회 메서드 추가 2025-09-17 10:34:35 +09:00
d5037664d7 feat: 일정 생성시 중복된 시간 검증 추가 2025-09-17 10:34:33 +09:00
9c279e1ec2 feat: 일정과 테마 정보를 담고 있는 도메인 객체 생성 2025-09-17 10:33:11 +09:00
b82c975cd0 fix: 오타 수정 2025-09-17 09:31:57 +09:00
6cfa2cbd38 refactor: \@LastModifiedBy 설정 휴먼에러 방지를 위한 ScheduleEntity 생성 전용 팩터리 메서드 추가 2025-09-16 22:06:50 +09:00
4e13735d5f refactor: ScheduleAPI 인터페이스를 각 권한 타입별로 분리 2025-09-16 22:06:06 +09:00
a6d028de45 refactor: 모든 관리자에 해당되는 AdminType.ALL 타입 추가 2025-09-16 22:03:11 +09:00
6ee7aa4339 refactor: ScheduleEntity 에서 updatedBy는 관리자 수정시에만 변경되도록 수정 2025-09-16 11:22:57 +09:00
afedaa21b8 refactor: 회원 / 관리자 PK 기반 MDC 유틸 분리 및 반영 2025-09-16 11:20:38 +09:00
cf3a1488f7 refactor: store 테이블에 status 컬럼 추가 및 Enum 정의 2025-09-15 17:50:29 +09:00
072ca7c457 remove: Room 테이블 제거 및 Schedule 테이블의 roomId -> storeId 수정 2025-09-15 17:49:19 +09:00
163b7991d3 refactor: 일정 관련 프론트엔드 페이지 및 기능 구현 2025-09-15 16:06:14 +09:00
cdf7a98867 refactor: StoreEntity에 'by' audit 정보 추가 2025-09-15 15:51:19 +09:00
2d138ff325 feat: 지역 코드 + 이름이 담긴 새로운 DTO 추가 2025-09-15 15:50:44 +09:00
bb6981666f refactor: Operator가 없으면 우선 Unknown을 반환하도록 수정 2025-09-15 15:50:27 +09:00
b41cddf345 refactor: Audit 관련 DTO 별도 클래스 분리 2025-09-15 15:46:21 +09:00
2481e026eb feat: 매장이 반영된 프론트엔드 관리자 페이지 초안 2025-09-15 15:45:35 +09:00
8cd1084bd8 fix: store 스키마에서 추가되지 않은 contact 컬럼 추가 2025-09-15 14:46:14 +09:00
b839c76a65 refactor: 테마 API에서의 변경 사항 및 HQ / STORE에 따른 어드민 페이지 프론트엔드 반영 2025-09-15 14:38:07 +09:00
78baa271bb refactor: 테마 API 테스트에 iniailize inline fun 반영 2025-09-15 13:33:17 +09:00
cc0316d77a refactor: sql 및 데이터 생성용 테스트 비활성화 2025-09-15 13:32:58 +09:00
747ecbf058 feat: 데이터 초기화 로직 수정(ID가 1이면 생성 -> 조회 후 없으면 생성) 2025-09-15 13:32:33 +09:00
d8fa110f3f refactor: 기존 테스트에 API 권한 변경 사항 반영 2025-09-15 13:31:55 +09:00
5fa5e5c49d refactor: 테스트에서의 초기화 작업 메서드에 가독성을 위한 println 로그 추가 2025-09-15 13:31:45 +09:00
78c699c69a refactor: 테마 API 테스트 권한별 분리 2025-09-15 12:30:45 +09:00
d78199778f refactor: AuthUtil 분리에 따른 RestAssuredUtils 정리 2025-09-15 12:30:08 +09:00
1de8d08cb7 refactor: Fixture의 중복값 문제를 해소하기 위한 랜덤 유틸 추가 2025-09-15 12:29:05 +09:00
228ea32db1 refactor: AuthUtil -> TestAuthUtil 네이밍 수정 및 클래스 분리 2025-09-15 12:28:29 +09:00
5b4df7bef6 refactor: DTO 명명 수정 및 테스트 반영 2025-09-15 12:09:05 +09:00
8205e83b4a refactor: DTO 명명 수정 및 테스트 반영 2025-09-15 12:08:52 +09:00
da88d66505 refactor: 테마 API를 권한별로 분리 2025-09-15 12:08:14 +09:00
06549e8ac1 refactor: Theme 스키마의 isOpen -> isActive 이름 변경 2025-09-15 11:55:07 +09:00
c3eceedea1 test: 테마 API 권한 변경에 따른 테스트 수정 2025-09-14 23:35:22 +09:00
7d2fd3b667 refactor: 기존의 테마 API를 모두 HQ 어드민 권한으로 수정 2025-09-14 23:16:38 +09:00
ecf0d6740a rename: ThemeAPIV2 -> ThemeAPI 클래스명 수정 2025-09-14 23:16:14 +09:00
ccac362551 refactor: \@AdminOnly에 기본 타입(STORE) 지정 - HQ 권한이 필요한 경우에만 2025-09-14 22:45:43 +09:00
d9ef3b0305 refactor: region에서의 미사용 DTO 제거 및 패키지 위치 수정 2025-09-14 22:43:41 +09:00
3ec96f3c35 refactor: API 및 로직 변동사항 테스트 반영 2025-09-14 22:41:33 +09:00
f27ce7cd3a refactor: 예약 취소에서 관리자 타입 체크 기능 제거 2025-09-14 22:41:16 +09:00
aecf499ea5 refactor: UserInterceptor에 관리자 토큰 검증 로직 추가 2025-09-14 22:40:55 +09:00
3d9a4c650e remove: 사용될 것 같지 않다고 판단되는 AdminResolver 및 관련 코드 제거 2025-09-14 22:40:39 +09:00
e3b0693a3c refactor: \@AdminOnly에 관리자 타입(STORE, HQ) 추가 2025-09-14 22:12:06 +09:00
5aa6a6cc2c refactor: 회원 / 관리자 Resolver 분리 2025-09-14 21:32:14 +09:00
c6dd8a977c refactor: 관리자 / 회원 분리에 따른 공통 API에 적용되는 Authenticated 어노테이션 및 인터셉터 제거 2025-09-14 21:29:24 +09:00
c3ab9be6c5 rename: CurrentUserContextResolver -> UserContextResolver 클래스명 수정 2025-09-14 21:20:23 +09:00
dcb4233f5d refactor: Resolver에서 사용하는 회원 어노테이션명 수정(CurrentUser -> User) 및 관리자 전용 Admin 추가 2025-09-14 21:19:31 +09:00
9b13448abd refactor: 기존 테스트에 변경된 인증 API 반영 2025-09-14 21:17:57 +09:00
498e8c8e75 refactor: Admin 변경에 따른 테스트용 유틸 재정의 2025-09-14 21:17:32 +09:00
e1aa032358 refactor: 미사용 인증 API 제거 및 로그인 API 반환 타입 재정의 2025-09-14 21:14:34 +09:00
a021ce8e73 refactor: 회원 / 관리자 로그인시 jwt claim 수정 2025-09-14 21:13:22 +09:00
63251d67ea refactor: 로그인시 사용하는 LoginCredential 회원 / 관리자 분리 및 헬퍼 메서드 추가 2025-09-14 21:13:06 +09:00
9361ea606b refactor: 프론트엔드에서 Admin 인증 Context 분리 및 별도의 로그인 페이지 생성 2025-09-14 21:10:29 +09:00
c33ec686f9 refactor: 관리자 스키마 타입 추가 및 엔티티 수정 2025-09-14 16:37:24 +09:00
18be393252 feat: RoomEntity 추가 2025-09-14 16:22:32 +09:00
993c593944 feat: store 테이블 초기 데이터 생성 코드 추가 2025-09-14 16:21:48 +09:00
12d75d2c66 feat: StoreEntity 추가 2025-09-14 16:21:36 +09:00
f4d7b30452 refactor: DataParser 내 상수화 및 필터링 처리 수정 2025-09-14 16:21:04 +09:00
16c890ae0e fix: store 테이블에서 사업자등록번호 컬럼 길이 수정 2025-09-14 16:08:41 +09:00
55abbdab6a fix: 행정동 제거 테스트 반영
- regionCode에서 dongCode 00000으로 수정
2025-09-14 15:02:18 +09:00
c3cf6e8097 refactor: 회원가입 페이지에서 행정동 입력 제외 2025-09-14 15:00:11 +09:00
7530e1038e feat: Apache POI gradle 의존성 및 행정안전부 인구현황 파일 전처리 유틸 클래스 추가 2025-09-14 14:52:20 +09:00
116dd24e26 refactor: region 스키마에서 행정동 컬럼 제거 & API 반영 2025-09-14 14:50:34 +09:00
407d7e9a5e refactor: 예약 취소 로직 수정
- 일단 await 으로 결제 취소 -> 예약 취소 순으로 진행
- 추후 개선 예정
2025-09-13 21:13:14 +09:00
b3ac4a2da2 fix: 테스트에서의 API endpoint 오타 수정 2025-09-13 21:12:39 +09:00
6fe3945129 feat: TosspayClient에 소요시간 로그 추가 2025-09-13 21:12:28 +09:00
a64371b3d2 feat: 회원가입 페이지에 지역 선택 추가 2025-09-13 21:12:13 +09:00
7db389ae49 feat: 지역 코드 조회 API 및 테스트 추가 2025-09-13 21:09:44 +09:00
361 changed files with 5314 additions and 11979 deletions

4
.gitignore vendored
View File

@ -37,7 +37,3 @@ out/
.vscode/
logs
.kotlin
### sql
data/*.sql
data/*.txt

View File

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

3
README.md Normal file
View File

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

View File

@ -1,58 +1,99 @@
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0"
id("io.spring.dependency-management") version "1.1.7" apply false
id("org.springframework.boot") version springBootVersion apply false
kotlin("jvm") version kotlinVersion apply false
kotlin("kapt") version kotlinVersion apply false
kotlin("plugin.spring") version kotlinVersion apply false
kotlin("plugin.jpa") version kotlinVersion apply false
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
kotlin("kapt") version kotlinVersion
}
group = "com.sangdol"
version = "0.0.1-SNAPSHOT"
allprojects {
repositories {
mavenCentral()
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.kapt")
apply(plugin = "io.spring.dependency-management")
tasks.jar {
enabled = false
}
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kapt {
keepJavacAnnotationProcessors = true
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true
}
repositories {
mavenCentral()
}
dependencies {
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
tasks.withType<Test> {
useJUnitPlatform()
}
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View File

@ -1,6 +0,0 @@
#!/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

@ -1,11 +0,0 @@
dependencies {
api("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.20.0")
implementation(project(":common:utils"))
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,9 +0,0 @@
package com.sangdol.common.log.constant
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
SUCCEED,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}

View File

@ -1,20 +0,0 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import javax.sql.DataSource
object SlowQueryDataSourceFactory {
fun create(dataSource: DataSource, loggerName: String, logLevel: String, thresholdMs: Long): DataSource {
val mdcAwareListener = MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(logLevel.uppercase()),
thresholdMs = thresholdMs
)
return ProxyDataSourceBuilder.create(dataSource)
.name(loggerName)
.listener(mdcAwareListener)
.buildProxy()
}
}

View File

@ -1,57 +0,0 @@
package com.sangdol.common.log
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
class TestLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("account", "address"),
objectMapper = jacksonObjectMapper()
)
class AbstractLogMaskingConverterTest : FunSpec({
val converter = TestLogMaskingConverter()
val event: ILoggingEvent = mockk()
val account = "sangdol@example.com"
val address = "서울특별시 강북구 수유1동 123-456"
context("sensitiveKeys=${converter.sensitiveKeys}에 있는 항목은 가린다.") {
context("평문 로그를 처리할 때, 여러 key / value가 있는 경우 서로 간의 구분자는 trim 처리한다.") {
listOf(":", "=", " : ", " = ").forEach { keyValueDelimiter ->
listOf(",", ", ").forEach { valueDelimiter ->
test("key1${keyValueDelimiter}value1${valueDelimiter}key2${keyValueDelimiter}value2 형식을 처리한다.") {
every {
event.formattedMessage
} returns "account$keyValueDelimiter$account${valueDelimiter}address$keyValueDelimiter$address"
assertSoftly(converter.convert(event)) {
this shouldBe "account${keyValueDelimiter}${account.first()}${converter.mask}${account.last()}${valueDelimiter}address${keyValueDelimiter}${address.first()}${converter.mask}${address.last()}"
}
}
}
}
}
context("JSON 로그") {
test("정상 처리") {
val json = "{\"request_body\":{\"account\":\"%s\",\"address\":\"%s\"}}"
every {
event.formattedMessage
} returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format(
"${account.first()}${converter.mask}${account.last()}",
"${address.first()}${converter.mask}${address.last()}"
)
}
}
}
})

View File

@ -1,29 +0,0 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-data-jpa")
api("com.github.f4b6a3:tsid-creator:5.2.6")
implementation(project(":common:utils"))
implementation(project(":common:types"))
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,13 +0,0 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
interface IDGenerator {
fun create(): Long
}
class TsidIDGenerator(
private val tsidFactory: TsidFactory
) : IDGenerator {
override fun create(): Long = tsidFactory.create().toLong()
}

View File

@ -1,25 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.*
import org.springframework.data.domain.Persistable
import kotlin.jvm.Transient
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -1,44 +0,0 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
import com.sangdol.common.utils.MdcPrincipalIdUtil
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.transaction.PlatformTransactionManager
import java.util.*
@Configuration
@EnableJpaAuditing
class PersistenceConfig {
@Value("\${POD_NAME:app-0}")
private lateinit var podName: String
@Bean
fun auditorAware(): AuditorAware<Long> = MdcAuditorAware()
@Bean
@Primary
fun idGenerator(): IDGenerator {
val node = podName.substringAfterLast("-").toInt()
val tsidFactory = TsidFactory.builder().withNode(node).build()
return TsidIDGenerator(tsidFactory)
}
@Bean
fun transactionExecutionUtil(
transactionManager: PlatformTransactionManager
): TransactionExecutionUtil {
return TransactionExecutionUtil(transactionManager)
}
}
class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<Long> = MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty()
}

View File

@ -1,53 +0,0 @@
package com.sangdol.common.persistence
import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.shouldBeBefore
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import java.time.LocalDateTime
@SpringBootTest
class BaseEntityTest(
private val testPersistableBaseEntityRepository: TestPersistableBaseEntityRepository,
private val testAuditingBaseEntityRepository: TestAuditingBaseEntityRepository,
private val idGenerator: IDGenerator
) : FunSpec({
context("TestPersistableBaseEntityRepository") {
test("PK를 지정하여 INSERT 쿼리를 한번만 전송한다.") {
val entity = TestPersistableBaseEntity(idGenerator.create(), "hello").also {
testPersistableBaseEntityRepository.saveAndFlush(it)
}
testPersistableBaseEntityRepository.findById(entity.id).also {
it.shouldNotBeNull()
it.get() shouldBeEqualUsingFields entity
}
}
}
context("TestAuditingBaseEntityRepository") {
test("Entity 저장 후 Audit 정보를 확인한다.") {
val id = idGenerator.create()
.also {
MdcPrincipalIdUtil.set(it.toString())
}.also {
testAuditingBaseEntityRepository.saveAndFlush(TestAuditingBaseEntity(it, "hello"))
}
testAuditingBaseEntityRepository.findById(id).also {
it.shouldNotBeNull()
assertSoftly(it.get()) {
this.createdAt shouldBeBefore LocalDateTime.now()
this.updatedAt shouldBeBefore LocalDateTime.now()
this.createdBy shouldBe id
this.updatedBy shouldBe id
}
}
}
}
})

View File

@ -1,6 +0,0 @@
package com.sangdol.common.persistence
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
class TestApplication

View File

@ -1,12 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestAuditingBaseEntity(
id: Long,
val name: String
) : AuditingBaseEntity(id)
interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>

View File

@ -1,12 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestPersistableBaseEntity(
id: Long,
val name: String
) : PersistableBaseEntity(id)
interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>

View File

@ -1,71 +0,0 @@
package com.sangdol.common.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.DefaultTransactionDefinition
class TransactionExecutionUtilTest() : FunSpec() {
private val transactionManager = mockk<PlatformTransactionManager>(relaxed = true)
private val transactionExecutionUtil = TransactionExecutionUtil(transactionManager)
init {
context("withNewTransaction") {
beforeTest {
clearMocks(transactionManager)
}
val body = TestPersistableBaseEntity(123458192L, "hello")
test("지정한 action이 성공하면, 해당 값을 반환하고 트랜잭션을 커밋한다.") {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
body
}.also {
it.shouldNotBeNull()
it shouldBeEqualUsingFields body
verify { transactionManager.commit(any()) }
verify(exactly = 0) { transactionManager.rollback(any()) }
}
}
test("지정한 action 실행 도중 예외가 발생하면, 예외를 던지고 트랜잭션을 롤백한다.") {
assertThrows<RuntimeException> {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
throw RuntimeException()
}
}.also {
verify { transactionManager.rollback(any()) }
verify(exactly = 0) { transactionManager.commit(any()) }
}
}
test("isReadOnly=true 지정시 읽기 전용 트랜잭션으로 실행한다.") {
val transactionStatus = mockk<TransactionStatus>(relaxed = true)
val transactionDefinitionSlot = slot<TransactionDefinition>()
every {
transactionManager.getTransaction(capture(transactionDefinitionSlot))
} returns transactionStatus
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
"hello"
}.also {
assertSoftly(transactionDefinitionSlot.captured) {
this.isReadOnly shouldBe true
this.propagationBehavior shouldBe DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW
}
verify { transactionManager.commit(any()) }
}
}
}
}
}

View File

@ -1,18 +0,0 @@
spring:
jpa:
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
username: sa
password:

View File

@ -1,3 +0,0 @@
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,24 +0,0 @@
package com.sangdol.common.types.web
enum class HttpStatus(
val code: Int
) {
OK(200),
CREATED(201),
NO_CONTENT(204),
BAD_REQUEST(400),
UNAUTHORIZED(401),
FORBIDDEN(403),
NOT_FOUND(404),
CONFLICT(409),
INTERNAL_SERVER_ERROR(500)
;
fun isClientError(): Boolean {
return code in 400..<500
}
fun value(): Int {
return code
}
}

View File

@ -1,7 +0,0 @@
dependencies {
implementation("org.slf4j:slf4j-api:2.0.17")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,21 +0,0 @@
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

@ -1,25 +0,0 @@
package com.sangdol.common.utils
import org.slf4j.MDC
object MdcStartTimeUtil {
const val MDC_START_TIME_KEY = "start_time"
fun extractDurationMsOrNull(): Long? {
return extractOrNull()?.let { System.currentTimeMillis() - it }
}
fun setCurrentTime() {
extractOrNull() ?: run {
MDC.put(MDC_START_TIME_KEY, System.currentTimeMillis().toString())
}
}
fun clear() {
MDC.remove(MDC_START_TIME_KEY)
}
private fun extractOrNull(): Long? {
return MDC.get(MDC_START_TIME_KEY)?.toLong()
}
}

View File

@ -1,45 +0,0 @@
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

@ -1,28 +0,0 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.util.*
class MdcPrincipalIdUtilTest : StringSpec({
val id = 1872847943L
"값을 설정한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.of(id)
}
}
"값을 제거한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
}
MdcPrincipalIdUtil.clear().also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe null
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.empty()
}
}
})

View File

@ -1,34 +0,0 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class MdcStartTimeUtilTest : FunSpec({
test("기존에 등록된 startTime 값을 기준으로 duration_ms를 구한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("기존에 등록된 startTime 값이 없으면 duration_ms는 null이다.") {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
test("현재 시간을 등록한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("등록된 시간을 지운다.") {
MdcStartTimeUtil.setCurrentTime().also {
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
}
MdcStartTimeUtil.clear().also {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
}
})

View File

@ -1,28 +0,0 @@
import org.gradle.kotlin.dsl.named
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-aop")
api("com.fasterxml.jackson.module:jackson-module-kotlin")
api(project(":common:log"))
api(project(":common:utils"))
api(project(":common:types"))
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,26 +0,0 @@
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,50 +0,0 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
companion object {
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm")
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
LocalDate::class.java,
LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addDeserializer(
LocalDate::class.java,
LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addSerializer(
LocalTime::class.java,
LocalTimeSerializer(LOCAL_TIME_FORMATTER)
)
.addDeserializer(
LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule
}

View File

@ -1,41 +0,0 @@
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

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

View File

@ -1,75 +0,0 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilder(
private val type: LogType,
private val servletRequest: HttpServletRequest,
private val payload: MutableMap<String, Any> = mutableMapOf("type" to type)
) {
fun endpoint(): LogPayloadBuilder {
payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}"
return this
}
fun clientIp(): LogPayloadBuilder {
servletRequest.remoteAddr?.let { payload["client_ip"] = it }
return this
}
fun userAgent(): LogPayloadBuilder {
servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it }
return this
}
fun queryString(): LogPayloadBuilder {
servletRequest.queryString?.let { payload["query_params"] = it }
return this
}
fun httpStatus(statusCode: Int?): LogPayloadBuilder {
statusCode?.let { payload["status_code"] = it }
return this
}
fun responseBody(body: Any?): LogPayloadBuilder {
body?.let { payload["response_body"] = it }
return this
}
fun durationMs(): LogPayloadBuilder {
MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it }
return this
}
fun principalId(): LogPayloadBuilder {
MdcPrincipalIdUtil.extractAsLongOrNull()
?.let { payload["principal_id"] = it }
?: run { payload["principal_id"] = "UNKNOWN" }
return this
}
fun exception(exception: Exception?): LogPayloadBuilder {
exception?.let {
payload["exception"] = mapOf(
"class" to it.javaClass.simpleName,
"message" to it.message
)
}
return this
}
fun additionalPayloads(payload: Map<String, Any>): LogPayloadBuilder {
this.payload.putAll(payload)
return this
}
fun build(): Map<String, Any> = payload
}

View File

@ -1,68 +0,0 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter(
private val objectMapper: ObjectMapper
) {
fun convertToHttpRequestMessage(servletRequest: HttpServletRequest): String {
val payload = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.queryString()
.clientIp()
.userAgent()
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToControllerInvokedMessage(
servletRequest: HttpServletRequest,
controllerPayload: Map<String, Any>
): String {
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint()
.principalId()
.additionalPayloads(controllerPayload)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToResponseMessage(
type: LogType,
servletRequest: HttpServletRequest,
httpStatusCode: Int,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val payload = LogPayloadBuilder(type = type, servletRequest = servletRequest)
.endpoint()
.httpStatus(httpStatusCode)
.durationMs()
.principalId()
.responseBody(responseBody)
.exception(exception)
.build()
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

@ -1,233 +0,0 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilderTest : FunSpec({
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("endpoint") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "$method $requestUri"
}
test("ServletRequest에서 null이 반환되면 그대로 들어간다.") {
every { servletRequest.method } returns null
every { servletRequest.requestURI } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "null null"
}
}
context("clientIp") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe remoteAddr
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.remoteAddr } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe null
}
}
context("userAgent") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe userAgent
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.getHeader("User-Agent") } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe null
}
}
context("queryString") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe queryString
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.queryString } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe null
}
}
context("httpStatus") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(200)
.build()
result["status_code"] shouldBe 200
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(null)
.build()
result["status_code"] shouldBe null
}
}
context("responseBody") {
test("정상 응답") {
val body = mapOf("key" to "value")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(body)
.build()
result["response_body"] shouldBe body
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(null)
.build()
result["response_body"] shouldBe null
}
}
context("durationMs") {
test("정상 응답") {
MdcStartTimeUtil.setCurrentTime()
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"].shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"] shouldBe null
}
}
context("principalId") {
test("정상 응답") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe principalId
MdcPrincipalIdUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe "UNKNOWN"
}
}
context("exception") {
test("정상 응답") {
val exception = RuntimeException("hello")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(exception)
.build()
result["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(null)
.build()
result["exception"] shouldBe null
}
}
context("additionalPayloads") {
test("정상 응답") {
val payload = mapOf(
"key1" to "value1",
"key2" to "value2"
)
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.additionalPayloads(payload)
.build()
result["key1"] shouldBe "value1"
result["key2"] shouldBe "value2"
}
}
})

View File

@ -1,194 +0,0 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverterTest : FunSpec({
val objectMapper = jacksonObjectMapper()
val converter = WebLogMessageConverter(objectMapper)
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("Http 요청 메시지를 변환한다.") {
test("정상 응답") {
val result = converter.convertToHttpRequestMessage(servletRequest)
result shouldBe """
{"type":"${LogType.INCOMING_HTTP_REQUEST.name}","endpoint":"$method $requestUri","query_params":"$queryString","client_ip":"$remoteAddr","user_agent":"$userAgent"}
""".trimIndent()
}
}
context("Controller 요청 메시지를 변환한다") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
test("정상 응답") {
val controllerPayload: Map<String, Any> = mapOf(
"controller_method" to "ThemeController.findThemeById(..)",
"path_variable" to mapOf("id" to "7599801744469560667")
)
val result = converter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
result shouldBe """
{"type":"${LogType.CONTROLLER_INVOKED.name}","endpoint":"$method $requestUri","principal_id":$principalId,"controller_method":"${controllerPayload["controller_method"]}","path_variable":{"id":"${7599801744469560667}"}}
""".trimIndent()
}
}
context("응답 메시지를 변환한다.") {
val principalId = 7599801744469560666
val body = mapOf(
"id" to 7599801744469560667,
"name" to "sangdol"
)
val exception = RuntimeException("hello")
beforeTest {
MdcPrincipalIdUtil.set(principalId.toString())
MdcStartTimeUtil.setCurrentTime()
}
afterTest {
MdcPrincipalIdUtil.clear()
MdcStartTimeUtil.clear()
}
test("응답 본문을 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe null
}
}
test("예외를 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외 + 응답 본문을 모두 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body,
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외, 응답 본문 모두 제외한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] 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

@ -1,14 +0,0 @@
services:
mysql-local:
image: mysql:8.4
container_name: mysql-local
restart: always
ports:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local
TZ: UTC
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

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

View File

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

View File

@ -1,12 +0,0 @@
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

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

View File

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

View File

@ -1,12 +1,6 @@
import apiClient from "@_api/apiClient";
import type {AuditInfo} from "@_api/common/commonTypes";
import type {
AdminScheduleSummaryListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleUpdateRequest,
ScheduleWithThemeListResponse
} from "./scheduleTypes";
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
import type { AuditInfo } from "@_api/common/commonTypes";
// admin
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {

View File

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

View File

@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis
const queryParams: string[] = [];
if (sidoCode && sidoCode.trim() !== '') {
queryParams.push(`sido=${sidoCode}`);
queryParams.push(`sidoCode=${sidoCode}`);
}
if (sigunguCode && sigunguCode.trim() !== '') {
queryParams.push(`sigungu=${sigunguCode}`);
queryParams.push(`sigunguCode=${sigunguCode}`);
}
const baseUrl = `/stores`;

View File

@ -42,7 +42,3 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
}
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
};

View File

@ -0,0 +1,28 @@
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,7 +1,8 @@
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
import '@_css/home-page-v2.css';
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
import {fetchThemesByIds} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
const HomePage: React.FC = () => {
@ -12,8 +13,19 @@ const HomePage: React.FC = () => {
useEffect(() => {
const fetchData = async () => {
try {
const themeFetchCount = 10;
const response = await fetchMostReservedThemes(themeFetchCount);
const themeIds = await fetchMostReservedThemeIds().then(res => {
const themeIds = res.themeIds;
if (themeIds.length === 0) {
setRanking([]);
return;
}
return themeIds;
})
if (themeIds === undefined) return;
if (themeIds.length === 0) return;
const response = await fetchThemesByIds({ themeIds: themeIds });
setRanking(response.themes.map(mapThemeResponse));
} catch (err) {
console.error('Error fetching ranking:', err);

View File

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

View File

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

View File

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

View File

@ -1,18 +1,17 @@
import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import type { SidoResponse, SigunguResponse } from '@_api/region/regionTypes';
import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
import {
type SimpleStoreResponse,
type StoreDetailResponse,
type StoreRegisterRequest,
type UpdateStoreRequest
} from '@_api/store/storeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext';
import { useAdminAuth } from '@_context/AdminAuthContext';
import '@_css/admin-store-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import React, { Fragment, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
const AdminStorePage: React.FC = () => {
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
@ -298,10 +297,10 @@ const AdminStorePage: React.FC = () => {
:</strong> {detailedStores[store.id].region.code}
</p>
<p>
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
<strong>:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
</p>
<p>
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
<strong>:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})

View File

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

View File

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

View File

@ -1,57 +0,0 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// Cache
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
// DB
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
// submodules
implementation(project(":common:persistence"))
implementation(project(":common:web"))
}
tasks.named<Jar>("jar") {
enabled = false
}

View File

@ -1,26 +0,0 @@
package com.sangdol.roomescape
import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.*
@EnableAsync
@EnableCaching
@EnableScheduling
@SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
)
class RoomescapeApplication
fun main(args: Array<String>) {
System.setProperty("user.timezone", "UTC")
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
val springApplication = SpringApplication(RoomescapeApplication::class.java)
springApplication.setBannerMode(Banner.Mode.OFF)
springApplication.run()
}

View File

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

View File

@ -1,29 +0,0 @@
package com.sangdol.roomescape.admin.dto
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
data class AdminLoginCredentials(
override val id: Long,
override val password: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
val permissionLevel: AdminPermissionLevel,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = AdminLoginSuccessResponse(
accessToken = accessToken,
name = name,
type = type,
storeId = storeId
)
}
data class AdminLoginSuccessResponse(
override val accessToken: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

@ -1,13 +0,0 @@
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,92 +0,0 @@
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,19 +0,0 @@
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

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

View File

@ -1,27 +0,0 @@
package com.sangdol.roomescape.auth.dto
import com.sangdol.roomescape.auth.business.domain.PrincipalType
data class LoginContext(
val ipAddress: String,
val userAgent: String,
)
data class LoginRequest(
val account: String,
val password: String,
val principalType: PrincipalType
)
abstract class LoginSuccessResponse {
abstract val accessToken: String
abstract val name: String
}
abstract class LoginCredentials {
abstract val id: Long
abstract val password: String
abstract val name: String
abstract fun toResponse(accessToken: String): LoginSuccessResponse
}

View File

@ -1,13 +0,0 @@
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

@ -1,33 +0,0 @@
package com.sangdol.roomescape.common.config
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PreDestroy
import jakarta.transaction.Transactional
import org.springframework.context.annotation.Profile
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {}
@Component
@Profile("!deploy & local")
class LocalDatabaseCleaner(
private val jdbcTemplate: JdbcTemplate
) {
@PreDestroy
@Transactional
fun clearAll() {
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" }
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
rs.getString(1).lowercase()
}.forEach {
jdbcTemplate.execute("TRUNCATE TABLE $it")
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" }
}
}

View File

@ -1,9 +0,0 @@
package com.sangdol.roomescape.common.config
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import com.sangdol.common.web.config.JacksonConfig
class RoomescapeLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("password", "accessToken", "phone"),
objectMapper = JacksonConfig().objectMapper()
)

View File

@ -1,14 +0,0 @@
package com.sangdol.roomescape.common.config
import io.swagger.v3.oas.models.OpenAPI
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SwaggerConfig {
@Bean
fun openAPI(): OpenAPI {
return OpenAPI()
}
}

View File

@ -1,20 +0,0 @@
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,19 +0,0 @@
package com.sangdol.roomescape.common.types
import java.time.Instant
data class Auditor(
val id: Long,
val name: String,
) {
companion object {
val UNKNOWN = Auditor(0, "Unknown")
}
}
data class AuditingInfo(
val createdAt: Instant,
val createdBy: Auditor,
val updatedAt: Instant,
val updatedBy: Auditor,
)

View File

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

View File

@ -1,69 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,25 +0,0 @@
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,154 +0,0 @@
package com.sangdol.roomescape.payment.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
import com.sangdol.roomescape.payment.dto.*
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.payment.mapper.toResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@Service
class PaymentService(
private val idGenerator: IDGenerator,
private val paymentClient: TosspayClient,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val transactionExecutionUtil: TransactionExecutionUtil,
private val eventPublisher: ApplicationEventPublisher
) {
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
try {
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
eventPublisher.publishEvent(it.toEvent(reservationId))
log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" }
}
} catch (e: Exception) {
when(e) {
is ExternalPaymentException -> {
val errorCode = if (e.httpStatusCode in 400..<500) {
PaymentErrorCode.PAYMENT_CLIENT_ERROR
} else {
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
"${errorCode.message}(${e.message})"
} else {
errorCode.message
}
throw PaymentException(errorCode, message)
}
else -> {
log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
}
}
fun cancel(userId: Long, request: PaymentCancelRequest) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
paymentKey = payment.paymentKey,
amount = payment.totalAmount,
cancelReason = request.cancelReason
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
clientCancelResponse.cancels.toEntity(
id = idGenerator.create(),
paymentId = payment.id,
cancelRequestedAt = request.requestedAt,
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}.also {
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
}
@Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
return payment?.toResponse(
detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toResponse()
).also {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
}
}
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
?: run {
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
.also {
if (it != null) {
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
} else {
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
}
}
}
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) {
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
} else {
log.warn { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
}
}
}
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) {
log.info { "[findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
} else {
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" }
}
}
}
}

View File

@ -1,38 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -1,22 +0,0 @@
package com.sangdol.roomescape.payment.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
interface PaymentAPI {
@Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment(
@User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -1,59 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,15 +0,0 @@
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,14 +0,0 @@
package com.sangdol.roomescape.payment.exception
import com.sangdol.common.types.exception.RoomescapeException
class PaymentException(
override val errorCode: PaymentErrorCode,
override val message: String = errorCode.message
) : RoomescapeException(errorCode, message)
class ExternalPaymentException(
val httpStatusCode: Int,
val errorCode: String,
override val message: String
) : RuntimeException(message)

View File

@ -1,40 +0,0 @@
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,32 +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.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

@ -1,113 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,30 +0,0 @@
package com.sangdol.roomescape.payment.web
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.docs.PaymentAPI
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/payments")
class PaymentController(
private val paymentService: PaymentService
) : PaymentAPI {
@PostMapping("/cancel")
override fun cancelPayment(
@User user: CurrentUserContext,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
paymentService.cancel(user.id, request)
return ResponseEntity.ok(CommonApiResponse())
}
}

View File

@ -1,198 +0,0 @@
package com.sangdol.roomescape.reservation.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService
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.ReservationException
import com.sangdol.roomescape.reservation.infrastructure.persistence.*
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.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.user.business.UserService
import com.sangdol.roomescape.user.dto.UserContactResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Service
class ReservationService(
private val reservationRepository: ReservationRepository,
private val reservationValidator: ReservationValidator,
private val scheduleService: ScheduleService,
private val userService: UserService,
private val themeService: ThemeService,
private val canceledReservationRepository: CanceledReservationRepository,
private val idGenerator: IDGenerator,
private val paymentService: PaymentService
) {
@Transactional
fun createPendingReservation(
user: CurrentUserContext,
request: PendingReservationCreateRequest
): PendingReservationCreateResponse {
log.debug { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
run {
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request)
}
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also {
reservationRepository.save(it)
}
return PendingReservationCreateResponse(reservation.id)
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
}
@Transactional
fun confirmReservation(id: Long) {
log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
run {
reservation.confirm()
scheduleService.changeStatus(
scheduleId = reservation.scheduleId,
currentStatus = ScheduleStatus.HOLD,
changeStatus = ScheduleStatus.RESERVED
)
}.also {
log.info { "[confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
}
}
@Transactional
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
log.debug { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId)
run {
scheduleService.changeStatus(
scheduleId = reservation.scheduleId,
currentStatus = ScheduleStatus.RESERVED,
changeStatus = ScheduleStatus.AVAILABLE
)
saveCanceledReservation(user, reservation, request.cancelReason)
reservation.cancel()
}.also {
log.info { "[cancelReservation] 예약 취소 완료: reservationId=${reservationId}" }
}
}
@Transactional(readOnly = true)
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
userId = user.id,
statuses = listOf(ReservationStatus.CONFIRMED, ReservationStatus.CANCELED)
)
return ReservationOverviewListResponse(reservations.map {
val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId)
val schedule = response.schedule
it.toOverviewResponse(
scheduleDate = schedule.date,
scheduleStartFrom = schedule.startFrom,
scheduleEndAt = schedule.endAt,
storeName = response.theme.name,
themeName = response.store.name
)
}).also {
log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
}
}
@Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationAdditionalResponse {
log.debug { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId)
val paymentDetail: PaymentResponse? = paymentService.findDetailByReservationId(id)
return reservation.toAdditionalResponse(
user = user,
payment = paymentDetail
).also {
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 {
log.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
?: run {
log.warn { "[findOrThrow] 예약 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
private fun saveCanceledReservation(
user: CurrentUserContext,
reservation: ReservationEntity,
cancelReason: String
) {
if (reservation.userId != user.id) {
log.warn { "[createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
}
CanceledReservationEntity(
id = idGenerator.create(),
reservationId = reservation.id,
canceledBy = user.id,
cancelReason = cancelReason,
canceledAt = Instant.now(),
status = CanceledReservationStatus.COMPLETED
).also {
canceledReservationRepository.save(it)
}
}
}

View File

@ -1,53 +0,0 @@
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.KotlinLogging
import org.springframework.stereotype.Component
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationValidator {
fun validateCanCreate(
schedule: ScheduleStateResponse,
theme: ThemeInfoResponse,
request: PendingReservationCreateRequest
) {
validateSchedule(schedule)
validateReservationInfo(theme, request)
}
private fun validateSchedule(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) {
log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
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) {
log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
}
if (theme.maxParticipants < request.participantCount) {
log.info { "[validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
}
}
}

View File

@ -1,5 +0,0 @@
package com.sangdol.roomescape.reservation.business.event
class ReservationConfirmEvent(
val reservationId: Long
)

View File

@ -1,34 +0,0 @@
package com.sangdol.roomescape.reservation.business.event
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
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
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationEventListener(
private val reservationRepository: ReservationRepository
) {
@Async
@EventListener
@Transactional
fun handleReservationConfirmEvent(event: ReservationConfirmEvent) {
val reservationId = event.reservationId
log.debug { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" }
val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId)
if (modifiedRows == 0) {
log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" }
}
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" }
}
}

View File

@ -1,49 +0,0 @@
package com.sangdol.roomescape.reservation.business.scheduler
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
class IncompletedReservationScheduler(
private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository
) {
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredHoldSchedule() {
log.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.debug { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
}
scheduleRepository.releaseHeldSchedules(targets).also {
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
}
}
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredReservation() {
log.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
}
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
}
}
}

View File

@ -1,44 +0,0 @@
package com.sangdol.roomescape.reservation.dto
import com.sangdol.roomescape.payment.dto.PaymentResponse
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.user.dto.UserContactResponse
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
data class ReservationOverviewResponse(
val id: Long,
val storeName: String,
val themeName: String,
val date: LocalDate,
val startFrom: LocalTime,
val endAt: LocalTime,
val status: ReservationStatus
)
data class ReservationAdditionalResponse(
val id: Long,
val reserver: ReserverInfo,
val user: UserContactResponse,
val applicationDateTime: Instant,
val payment: PaymentResponse?,
)
data class ReserverInfo(
val name: String,
val contact: String,
val participantCount: Short,
val requirement: String
)
data class ReservationOverviewListResponse(
val reservations: List<ReservationOverviewResponse>
)
data class ReservationStateResponse(
val id: Long,
val scheduleId: Long,
val status: ReservationStatus,
val createdAt: Instant
)

View File

@ -1,21 +0,0 @@
package com.sangdol.roomescape.reservation.dto
import jakarta.validation.constraints.NotEmpty
data class ReservationCancelRequest(
val cancelReason: String
)
data class PendingReservationCreateRequest(
val scheduleId: Long,
@NotEmpty
val reserverName: String,
@NotEmpty
val reserverContact: String,
val participantCount: Short,
val requirement: String
)
data class PendingReservationCreateResponse(
val id: Long
)

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