generated from pricelees/issue-pr-template
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3ff32f9f | |||
| f92c82a382 | |||
| c2b50c4dd7 | |||
| 2d08a20aff | |||
| c0df43f840 | |||
| 1bd2292ea0 | |||
| c64e613a2b | |||
| 70b9c58c15 | |||
| 5a6f7c4763 | |||
| 86aa4c3046 | |||
| 8c6222237b | |||
| 232e7882de | |||
| 0b8963b8c5 | |||
| bea544d0fc | |||
| 6a7e1906d2 | |||
| ed8b48712e | |||
| 42f0c8bcd3 | |||
| c7c8e9ddcf | |||
| 910b19c83a | |||
| 8a9ec2e216 | |||
| 08af1c7084 | |||
| 5f546f87da | |||
| 8321356051 | |||
| be18775271 | |||
| 45813fc04d | |||
| 48b4a7597f | |||
| 58f7297c48 | |||
| 638df9f110 | |||
| 4a30cc8c14 | |||
| 522d64cc8a | |||
| f62ac181ee | |||
| 4331acee31 | |||
| 7cfc7a4d9f | |||
| 6b0c9709ed | |||
| efb7148215 | |||
| 75f628c991 | |||
| 2f8c2a6a55 | |||
| 89ada4b146 | |||
| 54648a6c04 | |||
| b8cf1d6c9d | |||
| dc37ae6d1a | |||
| 7a6afc7282 | |||
| 7c967defcc | |||
| 7fd278aa43 | |||
| 0ef47b7f94 | |||
| cf65ccf915 | |||
| cb9125ef1d | |||
| 4aacaddcfc | |||
| c7f98c3515 | |||
| eec279c76f | |||
| d5037664d7 | |||
| 9c279e1ec2 | |||
| b82c975cd0 | |||
| 6cfa2cbd38 | |||
| 4e13735d5f | |||
| a6d028de45 | |||
| 6ee7aa4339 | |||
| afedaa21b8 | |||
| cf3a1488f7 | |||
| 072ca7c457 | |||
| 163b7991d3 | |||
| cdf7a98867 | |||
| 2d138ff325 | |||
| bb6981666f | |||
| b41cddf345 | |||
| 2481e026eb | |||
| 8cd1084bd8 | |||
| b839c76a65 | |||
| 78baa271bb | |||
| cc0316d77a | |||
| 747ecbf058 | |||
| d8fa110f3f | |||
| 5fa5e5c49d | |||
| 78c699c69a | |||
| d78199778f | |||
| 1de8d08cb7 | |||
| 228ea32db1 | |||
| 5b4df7bef6 | |||
| 8205e83b4a | |||
| da88d66505 | |||
| 06549e8ac1 | |||
| c3eceedea1 | |||
| 7d2fd3b667 | |||
| ecf0d6740a | |||
| ccac362551 | |||
| d9ef3b0305 | |||
| 3ec96f3c35 | |||
| f27ce7cd3a | |||
| aecf499ea5 | |||
| 3d9a4c650e | |||
| e3b0693a3c | |||
| 5aa6a6cc2c | |||
| c6dd8a977c | |||
| c3ab9be6c5 | |||
| dcb4233f5d | |||
| 9b13448abd | |||
| 498e8c8e75 | |||
| e1aa032358 | |||
| a021ce8e73 | |||
| 63251d67ea | |||
| 9361ea606b | |||
| c33ec686f9 | |||
| 18be393252 | |||
| 993c593944 | |||
| 12d75d2c66 | |||
| f4d7b30452 | |||
| 16c890ae0e | |||
| 55abbdab6a | |||
| c3cf6e8097 | |||
| 7530e1038e | |||
| 116dd24e26 | |||
| 407d7e9a5e | |||
| b3ac4a2da2 | |||
| 6fe3945129 | |||
| a64371b3d2 | |||
| 7db389ae49 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,7 +37,3 @@ out/
|
|||||||
.vscode/
|
.vscode/
|
||||||
logs
|
logs
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### sql
|
|
||||||
data/*.sql
|
|
||||||
data/*.txt
|
|
||||||
11
Dockerfile
11
Dockerfile
@ -1,9 +1,10 @@
|
|||||||
FROM amazoncorretto:17
|
FROM gradle:8-jdk17 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN ./gradlew bootjar --no-daemon
|
||||||
|
|
||||||
COPY service/build/libs/service.jar app.jar
|
FROM amazoncorretto:17
|
||||||
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
COPY --from=builder /app/build/libs/*.jar app.jar
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
101
build.gradle.kts
101
build.gradle.kts
@ -1,52 +1,94 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
val springBootVersion = "3.5.3"
|
val springBootVersion = "3.5.3"
|
||||||
val kotlinVersion = "2.2.0"
|
val kotlinVersion = "2.2.0"
|
||||||
|
|
||||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
id("org.springframework.boot") version springBootVersion
|
||||||
id("org.springframework.boot") version springBootVersion apply false
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
kotlin("jvm") version kotlinVersion apply false
|
kotlin("jvm") version kotlinVersion
|
||||||
kotlin("kapt") version kotlinVersion apply false
|
kotlin("plugin.spring") version kotlinVersion
|
||||||
kotlin("plugin.spring") version kotlinVersion apply false
|
kotlin("plugin.jpa") version kotlinVersion
|
||||||
kotlin("plugin.jpa") version kotlinVersion apply false
|
kotlin("kapt") version kotlinVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.sangdol"
|
group = "com.sangdol"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
allprojects {
|
java {
|
||||||
repositories {
|
toolchain {
|
||||||
mavenCentral()
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
tasks.jar {
|
||||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
enabled = false
|
||||||
apply(plugin = "org.jetbrains.kotlin.kapt")
|
}
|
||||||
apply(plugin = "io.spring.dependency-management")
|
|
||||||
|
|
||||||
extensions.configure<JavaPluginExtension> {
|
kapt {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions.configure<KaptExtension> {
|
|
||||||
keepJavacAnnotationProcessors = true
|
keepJavacAnnotationProcessors = true
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
repositories {
|
||||||
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
|
mavenCentral()
|
||||||
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
|
}
|
||||||
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test> {
|
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")
|
||||||
|
|
||||||
|
// API docs
|
||||||
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
|
||||||
|
|
||||||
|
// 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()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-Xjsr305=strict",
|
"-Xjsr305=strict",
|
||||||
@ -54,5 +96,4 @@ subprojects {
|
|||||||
)
|
)
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
build.sh
6
build.sh
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package com.sangdol.common.log.constant
|
|
||||||
|
|
||||||
enum class LogType {
|
|
||||||
INCOMING_HTTP_REQUEST,
|
|
||||||
CONTROLLER_INVOKED,
|
|
||||||
SUCCEED,
|
|
||||||
APPLICATION_FAILURE,
|
|
||||||
UNHANDLED_EXCEPTION
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.sangdol.common.persistence
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
class TestApplication
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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:
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
dependencies {
|
|
||||||
implementation("org.slf4j:slf4j-api:2.0.17")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
@ -1,18 +1,6 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
.git
|
||||||
|
|
||||||
# Build output
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Editor/OS specific
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
# Environment variables
|
dist
|
||||||
.env*
|
build
|
||||||
@ -1,17 +1,18 @@
|
|||||||
FROM node:24-alpine AS builder
|
# Stage 1: Build the React app
|
||||||
|
FROM node:24 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY package-lock.json ./
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
RUN npm install --frozen-lockfile
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
FROM nginx:1.27-alpine
|
|
||||||
|
|
||||||
|
# Stage 2: Serve with Nginx
|
||||||
|
FROM nginx:latest
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface OrderErrorResponse {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
trial: number;
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ export interface PaymentConfirmRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
paymentType: PaymentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentCancelRequest {
|
export interface PaymentCancelRequest {
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import apiClient from "@_api/apiClient";
|
import apiClient from "@_api/apiClient";
|
||||||
import type {AuditInfo} from "@_api/common/commonTypes";
|
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
|
||||||
import type {
|
import type { AuditInfo } from "@_api/common/commonTypes";
|
||||||
AdminScheduleSummaryListResponse,
|
|
||||||
ScheduleCreateRequest,
|
|
||||||
ScheduleCreateResponse,
|
|
||||||
ScheduleUpdateRequest,
|
|
||||||
ScheduleWithThemeListResponse
|
|
||||||
} from "./scheduleTypes";
|
|
||||||
|
|
||||||
// admin
|
// admin
|
||||||
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { Difficulty } from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||||
|
|
||||||
export const ScheduleStatus = {
|
export const ScheduleStatus = {
|
||||||
@ -38,33 +40,14 @@ export interface AdminScheduleSummaryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
export interface ScheduleResponse {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
startFrom: string;
|
|
||||||
endAt: string;
|
|
||||||
status: ScheduleStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleThemeInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleStoreInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleWithStoreAndThemeResponse {
|
|
||||||
schedule: ScheduleResponse,
|
|
||||||
theme: ScheduleThemeInfo,
|
|
||||||
store: ScheduleStoreInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleWithThemeResponse {
|
export interface ScheduleWithThemeResponse {
|
||||||
schedule: ScheduleResponse,
|
id: string,
|
||||||
theme: ScheduleThemeInfo
|
startFrom: string,
|
||||||
|
endAt: string,
|
||||||
|
themeId: string,
|
||||||
|
themeName: string,
|
||||||
|
themeDifficulty: Difficulty,
|
||||||
|
status: ScheduleStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleWithThemeListResponse {
|
export interface ScheduleWithThemeListResponse {
|
||||||
|
|||||||
@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis
|
|||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
|
|
||||||
if (sidoCode && sidoCode.trim() !== '') {
|
if (sidoCode && sidoCode.trim() !== '') {
|
||||||
queryParams.push(`sido=${sidoCode}`);
|
queryParams.push(`sidoCode=${sidoCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sigunguCode && sigunguCode.trim() !== '') {
|
if (sigunguCode && sigunguCode.trim() !== '') {
|
||||||
queryParams.push(`sigungu=${sigunguCode}`);
|
queryParams.push(`sigunguCode=${sigunguCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `/stores`;
|
const baseUrl = `/stores`;
|
||||||
|
|||||||
@ -42,7 +42,3 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
|
|||||||
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
||||||
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
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}`);
|
|
||||||
};
|
|
||||||
|
|||||||
28
frontend/src/components/AdminRoute.tsx
Normal file
28
frontend/src/components/AdminRoute.tsx
Normal 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;
|
||||||
@ -1,7 +1,8 @@
|
|||||||
|
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/home-page-v2.css';
|
import '@_css/home-page-v2.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
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';
|
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
@ -12,8 +13,19 @@ const HomePage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const themeFetchCount = 10;
|
const themeIds = await fetchMostReservedThemeIds().then(res => {
|
||||||
const response = await fetchMostReservedThemes(themeFetchCount);
|
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));
|
setRanking(response.themes.map(mapThemeResponse));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching ranking:', err);
|
console.error('Error fetching ranking:', err);
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||||
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||||||
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
import {getStores} from '@_api/store/storeAPI';
|
||||||
import { getStores } from '@_api/store/storeAPI';
|
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||||||
import { fetchThemeById } from '@_api/theme/themeAPI';
|
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||||||
|
import {formatDate} from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationStep1Page: React.FC = () => {
|
const ReservationStep1Page: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
@ -60,13 +60,9 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
}, [selectedSido]);
|
}, [selectedSido]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSido) {
|
|
||||||
getStores(selectedSido, selectedSigungu)
|
getStores(selectedSido, selectedSigungu)
|
||||||
.then(res => setStoreList(res.stores))
|
.then(res => setStoreList(res.stores))
|
||||||
.catch(handleError);
|
.catch(handleError);
|
||||||
} else {
|
|
||||||
setStoreList([]);
|
|
||||||
}
|
|
||||||
setSelectedStore(null);
|
setSelectedStore(null);
|
||||||
}, [selectedSido, selectedSigungu]);
|
}, [selectedSido, selectedSigungu]);
|
||||||
|
|
||||||
@ -76,7 +72,7 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
fetchSchedules(selectedStore.id, dateStr)
|
fetchSchedules(selectedStore.id, dateStr)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||||
const key = schedule.theme.name;
|
const key = schedule.themeName;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(schedule);
|
acc[key].push(schedule);
|
||||||
return acc;
|
return acc;
|
||||||
@ -111,11 +107,11 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
const handleConfirmReservation = () => {
|
const handleConfirmReservation = () => {
|
||||||
if (!selectedSchedule) return;
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
holdSchedule(selectedSchedule.schedule.id)
|
holdSchedule(selectedSchedule.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||||||
const reservationData: ReservationData = {
|
const reservationData: ReservationData = {
|
||||||
scheduleId: selectedSchedule.schedule.id,
|
scheduleId: selectedSchedule.id,
|
||||||
store: {
|
store: {
|
||||||
id: selectedStore!.id,
|
id: selectedStore!.id,
|
||||||
name: selectedStore!.name,
|
name: selectedStore!.name,
|
||||||
@ -128,8 +124,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
maxParticipants: res.maxParticipants,
|
maxParticipants: res.maxParticipants,
|
||||||
},
|
},
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
startFrom: selectedSchedule.schedule.startFrom,
|
startFrom: selectedSchedule.startFrom,
|
||||||
endAt: selectedSchedule.schedule.endAt,
|
endAt: selectedSchedule.endAt,
|
||||||
};
|
};
|
||||||
navigate('/reservation/form', {state: reservationData});
|
navigate('/reservation/form', {state: reservationData});
|
||||||
}).catch(handleError);
|
}).catch(handleError);
|
||||||
@ -248,23 +244,23 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<h3>3. 시간 선택</h3>
|
<h3>3. 시간 선택</h3>
|
||||||
<div className="schedule-list">
|
<div className="schedule-list">
|
||||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||||
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
||||||
<div key={themeName} className="theme-schedule-group">
|
<div key={themeName} className="theme-schedule-group">
|
||||||
<div className="theme-header">
|
<div className="theme-header">
|
||||||
<h4>{themeName}</h4>
|
<h4>{themeName}</h4>
|
||||||
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||||||
className="theme-detail-button">상세보기
|
className="theme-detail-button">상세보기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-slots">
|
<div className="time-slots">
|
||||||
{scheduleAndTheme.map(schedule => (
|
{schedules.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.schedule.id}
|
key={schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||||||
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -313,8 +309,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<div className="modal-section modal-info-grid">
|
<div className="modal-section modal-info-grid">
|
||||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||||
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||||||
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { confirm } from '@_api/order/orderAPI';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import type { OrderErrorResponse } from '@_api/order/orderTypes';
|
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
||||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
@ -22,6 +21,17 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
||||||
|
|
||||||
|
const handleError = (err: any) => {
|
||||||
|
if (isLoginRequiredError(err)) {
|
||||||
|
alert('로그인이 필요해요.');
|
||||||
|
navigate('/login', { state: { from: location } });
|
||||||
|
} else {
|
||||||
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
alert(message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reservationId) {
|
if (!reservationId) {
|
||||||
alert('잘못된 접근입니다.');
|
alert('잘못된 접근입니다.');
|
||||||
@ -67,8 +77,13 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: totalPrice,
|
amount: totalPrice,
|
||||||
|
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||||
};
|
};
|
||||||
confirm(reservationId, paymentData)
|
|
||||||
|
confirmPayment(reservationId, paymentData)
|
||||||
|
.then(() => {
|
||||||
|
return confirmReservation(reservationId);
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
alert('결제가 완료되었어요!');
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/reservation/success', {
|
navigate('/reservation/success', {
|
||||||
@ -82,50 +97,10 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(handleError);
|
||||||
const error = err as AxiosError<OrderErrorResponse>;
|
|
||||||
const errorCode = error.response?.data?.code;
|
|
||||||
const errorMessage = error.response?.data?.message;
|
|
||||||
|
|
||||||
if (errorCode === 'B000') {
|
|
||||||
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
|
|
||||||
navigate('/reservation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trial = error.response?.data?.trial || 0;
|
|
||||||
if (trial < 2) {
|
|
||||||
alert(errorMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
alert(errorMessage);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
|
|
||||||
|
|
||||||
if (agreeToOnsitePayment) {
|
|
||||||
confirmReservation(reservationId)
|
|
||||||
.then(() => {
|
|
||||||
navigate('/reservation/success', {
|
|
||||||
state: {
|
|
||||||
storeName,
|
|
||||||
themeName,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
participantCount,
|
|
||||||
totalPrice,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
console.error("Payment request error:", error);
|
console.error("Payment request error:", error);
|
||||||
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
alert("결제 요청 중 오류가 발생했습니다.");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,10 @@ import {
|
|||||||
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
||||||
import {getStores} from '@_api/store/storeAPI';
|
import {getStores} from '@_api/store/storeAPI';
|
||||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||||
import {fetchActiveThemes} from '@_api/theme/themeAPI';
|
import {fetchActiveThemes, fetchThemeById} from '@_api/theme/themeAPI';
|
||||||
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
import {useAdminAuth} from '@_context/AdminAuthContext';
|
||||||
import '@_css/admin-schedule-page.css';
|
import '@_css/admin-schedule-page.css';
|
||||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
|
||||||
import React, {Fragment, useEffect, useState} from 'react';
|
import React, {Fragment, useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
@ -54,8 +53,8 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||||
const [isLoadingThemeDetails] = useState<boolean>(false);
|
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -333,10 +332,10 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p>
|
<p>
|
||||||
<strong>생성일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
|
<strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>수정일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
|
<strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||||
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
|
import type { SidoResponse, SigunguResponse } from '@_api/region/regionTypes';
|
||||||
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
|
import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
|
||||||
import {
|
import {
|
||||||
type SimpleStoreResponse,
|
type SimpleStoreResponse,
|
||||||
type StoreDetailResponse,
|
type StoreDetailResponse,
|
||||||
type StoreRegisterRequest,
|
type StoreRegisterRequest,
|
||||||
type UpdateStoreRequest
|
type UpdateStoreRequest
|
||||||
} from '@_api/store/storeTypes';
|
} from '@_api/store/storeTypes';
|
||||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
import { useAdminAuth } from '@_context/AdminAuthContext';
|
||||||
import '@_css/admin-store-page.css';
|
import '@_css/admin-store-page.css';
|
||||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
import React, { Fragment, useEffect, useState } from 'react';
|
||||||
import React, {Fragment, useEffect, useState} from 'react';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
|
||||||
|
|
||||||
const AdminStorePage: React.FC = () => {
|
const AdminStorePage: React.FC = () => {
|
||||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
||||||
@ -298,10 +297,10 @@ const AdminStorePage: React.FC = () => {
|
|||||||
코드:</strong> {detailedStores[store.id].region.code}
|
코드:</strong> {detailedStores[store.id].region.code}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
|
<strong>생성일:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>수정일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
|
<strong>수정일:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
||||||
|
|||||||
@ -9,8 +9,7 @@ import {
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
||||||
import '@_css/admin-theme-edit-page.css';
|
import '@_css/admin-theme-edit-page.css';
|
||||||
import type {AuditInfo} from '@_api/common/commonTypes';
|
import type { AuditInfo } from '@_api/common/commonTypes';
|
||||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
|
||||||
|
|
||||||
interface ThemeFormData {
|
interface ThemeFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@ -257,8 +256,8 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>생성일:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
|
<p><strong>생성일:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
|
||||||
<p><strong>수정일:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
|
<p><strong>수정일:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
|
||||||
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
||||||
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business.domain
|
|
||||||
|
|
||||||
enum class PrincipalType {
|
|
||||||
USER, ADMIN
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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] 데이터베이스 초기화 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
)
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package com.sangdol.roomescape.common.types
|
|
||||||
|
|
||||||
data class CurrentUserContext(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -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", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
|
||||||
;
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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()
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.business.event
|
|
||||||
|
|
||||||
class ReservationConfirmEvent(
|
|
||||||
val reservationId: Long
|
|
||||||
)
|
|
||||||
@ -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] 예약 확정 이벤트 처리 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}개의 예약 및 일정 처리 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user