generated from pricelees/issue-pr-template
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e572c842c | |||
| 7a236a8196 | |||
| 0756e21b63 | |||
| 162e5bbc79 | |||
| be2e6c606e | |||
| 06f7faf7f9 | |||
| 79de5c9c63 | |||
| 5f2e44bb11 | |||
| bba3266f3f | |||
| 135b13a9bf | |||
| 047e4a395b | |||
| 8215492eea | |||
| 186d6e118c | |||
| d056e12278 | |||
| 97c3e1598c | |||
| 32b8019576 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -36,4 +36,8 @@ out/
|
|||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
logs
|
logs
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### sql
|
||||||
|
data/*.sql
|
||||||
|
data/*.txt
|
||||||
11
Dockerfile
11
Dockerfile
@ -1,10 +1,9 @@
|
|||||||
FROM gradle:8-jdk17 AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
RUN ./gradlew bootjar --no-daemon
|
|
||||||
|
|
||||||
FROM amazoncorretto:17
|
FROM amazoncorretto:17
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY service/build/libs/service.jar app.jar
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=builder /app/build/libs/*.jar app.jar
|
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
115
build.gradle.kts
115
build.gradle.kts
@ -1,99 +1,58 @@
|
|||||||
|
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("org.springframework.boot") version springBootVersion
|
id("io.spring.dependency-management") version "1.1.7" apply false
|
||||||
id("io.spring.dependency-management") version "1.1.7"
|
id("org.springframework.boot") version springBootVersion apply false
|
||||||
kotlin("jvm") version kotlinVersion
|
kotlin("jvm") version kotlinVersion apply false
|
||||||
kotlin("plugin.spring") version kotlinVersion
|
kotlin("kapt") version kotlinVersion apply false
|
||||||
kotlin("plugin.jpa") version kotlinVersion
|
kotlin("plugin.spring") version kotlinVersion apply false
|
||||||
kotlin("kapt") version kotlinVersion
|
kotlin("plugin.jpa") version kotlinVersion apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.sangdol"
|
group = "com.sangdol"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
java {
|
allprojects {
|
||||||
toolchain {
|
repositories {
|
||||||
languageVersion = JavaLanguageVersion.of(17)
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.jar {
|
subprojects {
|
||||||
enabled = false
|
apply(plugin = "org.jetbrains.kotlin.jvm")
|
||||||
}
|
apply(plugin = "org.jetbrains.kotlin.kapt")
|
||||||
|
apply(plugin = "io.spring.dependency-management")
|
||||||
|
|
||||||
kapt {
|
extensions.configure<JavaPluginExtension> {
|
||||||
keepJavacAnnotationProcessors = true
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
extensions.configure<KaptExtension> {
|
||||||
mavenCentral()
|
keepJavacAnnotationProcessors = true
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Spring
|
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
}
|
||||||
|
|
||||||
// API docs
|
tasks.withType<Test> {
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
// DB
|
tasks.withType<KotlinCompile> {
|
||||||
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
|
compilerOptions {
|
||||||
runtimeOnly("com.h2database:h2")
|
freeCompilerArgs.addAll(
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
"-Xjsr305=strict",
|
||||||
|
"-Xannotation-default-target=param-property"
|
||||||
// Jwt
|
)
|
||||||
implementation("io.jsonwebtoken:jjwt:0.12.6")
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
// Logging
|
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
|
||||||
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
|
|
||||||
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
|
|
||||||
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
|
|
||||||
|
|
||||||
// Observability
|
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
|
||||||
implementation("io.micrometer:micrometer-tracing-bridge-otel")
|
|
||||||
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
|
|
||||||
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
|
||||||
|
|
||||||
// Kotlin
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
|
||||||
|
|
||||||
// Test
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
|
||||||
testImplementation("io.mockk:mockk:1.14.4")
|
|
||||||
testImplementation("com.ninja-squad:springmockk:4.0.2")
|
|
||||||
|
|
||||||
// Kotest
|
|
||||||
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
|
|
||||||
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
|
|
||||||
|
|
||||||
// RestAssured
|
|
||||||
testImplementation("io.rest-assured:rest-assured:5.5.5")
|
|
||||||
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
|
|
||||||
|
|
||||||
// etc
|
|
||||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test> {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<KotlinCompile> {
|
|
||||||
compilerOptions {
|
|
||||||
freeCompilerArgs.addAll(
|
|
||||||
"-Xjsr305=strict",
|
|
||||||
"-Xannotation-default-target=param-property"
|
|
||||||
)
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
build.sh
Executable file
6
build.sh
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
IMAGE_NAME="roomescape-backend"
|
||||||
|
IMAGE_TAG=$1
|
||||||
|
|
||||||
|
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push
|
||||||
11
common/log/build.gradle.kts
Normal file
11
common/log/build.gradle.kts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.sangdol.common.log.constant
|
||||||
|
|
||||||
|
enum class LogType {
|
||||||
|
INCOMING_HTTP_REQUEST,
|
||||||
|
CONTROLLER_INVOKED,
|
||||||
|
SUCCEED,
|
||||||
|
APPLICATION_FAILURE,
|
||||||
|
UNHANDLED_EXCEPTION
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.common.log.message
|
||||||
|
|
||||||
import ch.qos.logback.classic.pattern.MessageConverter
|
import ch.qos.logback.classic.pattern.MessageConverter
|
||||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||||
@ -7,13 +7,14 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||||||
import com.fasterxml.jackson.databind.node.ArrayNode
|
import com.fasterxml.jackson.databind.node.ArrayNode
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||||
import com.fasterxml.jackson.databind.node.TextNode
|
import com.fasterxml.jackson.databind.node.TextNode
|
||||||
import roomescape.common.config.JacksonConfig
|
|
||||||
|
|
||||||
private const val MASK: String = "****"
|
abstract class AbstractLogMaskingConverter(
|
||||||
private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone")
|
val sensitiveKeys: Set<String>,
|
||||||
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
val objectMapper: ObjectMapper
|
||||||
|
) : MessageConverter() {
|
||||||
|
|
||||||
|
val mask: String = "****"
|
||||||
|
|
||||||
class RoomescapeLogMaskingConverter : MessageConverter() {
|
|
||||||
override fun convert(event: ILoggingEvent): String {
|
override fun convert(event: ILoggingEvent): String {
|
||||||
val message: String = event.formattedMessage
|
val message: String = event.formattedMessage
|
||||||
|
|
||||||
@ -35,13 +36,13 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
|
|||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
private fun maskedPlainMessage(message: String): String {
|
private fun maskedPlainMessage(message: String): String {
|
||||||
val keys: String = SENSITIVE_KEYS.joinToString("|")
|
val keys: String = sensitiveKeys.joinToString("|")
|
||||||
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)")
|
val regex = Regex("(?i)($keys)(\\s*[:=]\\s*)([^(,|\"|?)]+)")
|
||||||
|
|
||||||
return regex.replace(message) { matchResult ->
|
return regex.replace(message) { matchResult ->
|
||||||
val key = matchResult.groupValues[1]
|
val key = matchResult.groupValues[1]
|
||||||
val delimiter = matchResult.groupValues[2]
|
val delimiter = matchResult.groupValues[2]
|
||||||
val maskedValue = maskValue(matchResult.groupValues[3])
|
val maskedValue = maskValue(matchResult.groupValues[3].trim())
|
||||||
|
|
||||||
"${key}${delimiter}${maskedValue}"
|
"${key}${delimiter}${maskedValue}"
|
||||||
}
|
}
|
||||||
@ -51,7 +52,7 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
|
|||||||
node?.forEachEntry { key, childNode ->
|
node?.forEachEntry { key, childNode ->
|
||||||
when {
|
when {
|
||||||
childNode.isValueNode -> {
|
childNode.isValueNode -> {
|
||||||
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, maskValue(childNode.asText()))
|
if (key in sensitiveKeys) (node as ObjectNode).put(key, maskValue(childNode.asText()))
|
||||||
}
|
}
|
||||||
|
|
||||||
childNode.isObject -> maskRecursive(childNode)
|
childNode.isObject -> maskRecursive(childNode)
|
||||||
@ -72,10 +73,6 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun maskValue(value: String): String {
|
private fun maskValue(value: String): String {
|
||||||
return if (value.length <= 2) {
|
return "${value.first()}$mask${value.last()}"
|
||||||
MASK
|
|
||||||
} else {
|
|
||||||
"${value.first()}$MASK${value.last()}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.common.log.sql
|
||||||
|
|
||||||
import net.ttddyy.dsproxy.ExecutionInfo
|
import net.ttddyy.dsproxy.ExecutionInfo
|
||||||
import net.ttddyy.dsproxy.QueryInfo
|
import net.ttddyy.dsproxy.QueryInfo
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
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,5 +1,7 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.common.log
|
||||||
|
|
||||||
|
import com.sangdol.common.log.sql.SlowQueryPredicate
|
||||||
|
import com.sangdol.common.log.sql.SqlLogFormatter
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.core.spec.style.StringSpec
|
import io.kotest.core.spec.style.StringSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
@ -18,9 +20,9 @@ class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
|
|||||||
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
|
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
|
||||||
|
|
||||||
assertSoftly(slowQueryPredicate) {
|
assertSoftly(slowQueryPredicate) {
|
||||||
it.test(slowQueryThreshold) shouldBe true
|
this.test(slowQueryThreshold) shouldBe true
|
||||||
it.test(slowQueryThreshold + 1) shouldBe true
|
this.test(slowQueryThreshold + 1) shouldBe true
|
||||||
it.test(slowQueryThreshold - 1) shouldBe false
|
this.test(slowQueryThreshold - 1) shouldBe false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
29
common/persistence/build.gradle.kts
Normal file
29
common/persistence/build.gradle.kts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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,14 +1,14 @@
|
|||||||
package roomescape.common.entity
|
package com.sangdol.common.persistence
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.EntityListeners
|
||||||
|
import jakarta.persistence.MappedSuperclass
|
||||||
import org.springframework.data.annotation.CreatedBy
|
import org.springframework.data.annotation.CreatedBy
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedBy
|
import org.springframework.data.annotation.LastModifiedBy
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
import org.springframework.data.domain.Persistable
|
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import kotlin.jvm.Transient
|
|
||||||
|
|
||||||
@MappedSuperclass
|
@MappedSuperclass
|
||||||
@EntityListeners(AuditingEntityListener::class)
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
|
|||||||
) : PersistableBaseEntity(id) {
|
) : PersistableBaseEntity(id) {
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
lateinit var createdAt: LocalDateTime
|
lateinit var createdAt: Instant
|
||||||
|
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
@CreatedBy
|
@CreatedBy
|
||||||
@ -25,29 +25,9 @@ abstract class AuditingBaseEntity(
|
|||||||
|
|
||||||
@Column
|
@Column
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
lateinit var updatedAt: LocalDateTime
|
lateinit var updatedAt: Instant
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
@LastModifiedBy
|
@LastModifiedBy
|
||||||
var updatedBy: Long = 0L
|
var updatedBy: Long = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
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,31 +1,19 @@
|
|||||||
package roomescape.common.util
|
package com.sangdol.common.persistence
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.transaction.PlatformTransactionManager
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
import org.springframework.transaction.TransactionDefinition
|
import org.springframework.transaction.TransactionDefinition
|
||||||
import org.springframework.transaction.support.TransactionTemplate
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
import roomescape.common.exception.CommonErrorCode
|
|
||||||
import roomescape.common.exception.RoomescapeException
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class TransactionExecutionUtil(
|
class TransactionExecutionUtil(
|
||||||
private val transactionManager: PlatformTransactionManager
|
private val transactionManager: PlatformTransactionManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T): T {
|
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T?): T? {
|
||||||
val transactionTemplate = TransactionTemplate(transactionManager).apply {
|
val transactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||||
this.isReadOnly = isReadOnly
|
this.isReadOnly = isReadOnly
|
||||||
this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
return transactionTemplate.execute { action() }
|
return transactionTemplate.execute { action() }
|
||||||
?: run {
|
|
||||||
log.error { "[TransactionExecutionUtil.withNewTransaction] 트랜잭션 작업 중 예상치 못한 null 반환 " }
|
|
||||||
throw RoomescapeException(CommonErrorCode.UNEXPECTED_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.sangdol.common.persistence
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
class TestApplication
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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>
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
common/persistence/src/test/resources/application.yaml
Normal file
18
common/persistence/src/test/resources/application.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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:
|
||||||
3
common/types/build.gradle.kts
Normal file
3
common/types/build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
tasks.named<Jar>("jar") {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package roomescape.common.exception
|
package com.sangdol.common.types.exception
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
enum class CommonErrorCode(
|
enum class CommonErrorCode(
|
||||||
override val httpStatus: HttpStatus,
|
override val httpStatus: HttpStatus,
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package roomescape.common.exception
|
package com.sangdol.common.types.exception
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
interface ErrorCode {
|
interface ErrorCode {
|
||||||
val httpStatus: HttpStatus
|
val httpStatus: HttpStatus
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package roomescape.common.exception
|
package com.sangdol.common.types.exception
|
||||||
|
|
||||||
open class RoomescapeException(
|
open class RoomescapeException(
|
||||||
open val errorCode: ErrorCode,
|
open val errorCode: ErrorCode,
|
||||||
override val message: String = errorCode.message
|
override val message: String = errorCode.message
|
||||||
) : RuntimeException(message)
|
) : RuntimeException(message)
|
||||||
@ -1,9 +1,7 @@
|
|||||||
package roomescape.common.dto.response
|
package com.sangdol.common.types.web
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
import roomescape.common.exception.ErrorCode
|
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
data class CommonApiResponse<T>(
|
data class CommonApiResponse<T>(
|
||||||
val data: T? = null,
|
val data: T? = null,
|
||||||
)
|
)
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
7
common/utils/build.gradle.kts
Normal file
7
common/utils/build.gradle.kts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
dependencies {
|
||||||
|
implementation("org.slf4j:slf4j-api:2.0.17")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<Jar>("jar") {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.sangdol.common.utils
|
||||||
|
|
||||||
|
import java.time.*
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
|
||||||
|
|
||||||
|
object KoreaDate {
|
||||||
|
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
|
||||||
|
}
|
||||||
|
|
||||||
|
object KoreaTime {
|
||||||
|
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
object KoreaDateTime {
|
||||||
|
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
|
||||||
|
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()
|
||||||
@ -1,11 +1,10 @@
|
|||||||
package roomescape.common.util
|
package com.sangdol.common.utils
|
||||||
|
|
||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
private const val MDC_PRINCIPAL_ID_KEY = "principal_id"
|
object MdcPrincipalIdUtil {
|
||||||
|
const val MDC_PRINCIPAL_ID_KEY = "principal_id"
|
||||||
object MdcPrincipalId {
|
|
||||||
|
|
||||||
fun extractAsLongOrNull(): Long? {
|
fun extractAsLongOrNull(): Long? {
|
||||||
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
|
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.sangdol.common.utils
|
||||||
|
|
||||||
|
import io.kotest.assertions.assertSoftly
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import java.time.*
|
||||||
|
|
||||||
|
class KoreaDateTimeExtensionsTest : FunSpec({
|
||||||
|
|
||||||
|
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
|
||||||
|
assertSoftly(KoreaTime.now()) {
|
||||||
|
val utcNow = LocalTime.now(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
this.hour shouldBe utcNow.hour.plus(9)
|
||||||
|
this.minute shouldBe utcNow.minute
|
||||||
|
this.second shouldBe 0
|
||||||
|
this.nano shouldBe 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
|
||||||
|
assertSoftly(KoreaDateTime.now()) {
|
||||||
|
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
|
||||||
|
assertSoftly(KoreaDateTime.nowWithOffset()) {
|
||||||
|
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
|
||||||
|
.withSecond(0).withNano(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
|
||||||
|
val now = Instant.now()
|
||||||
|
val kstConverted = now.toKoreaDateTime()
|
||||||
|
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
|
||||||
|
|
||||||
|
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
28
common/web/build.gradle.kts
Normal file
28
common/web/build.gradle.kts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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,5 +1,7 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.common.web.asepct
|
||||||
|
|
||||||
|
import com.sangdol.common.log.constant.LogType
|
||||||
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -9,7 +11,6 @@ import org.aspectj.lang.annotation.Around
|
|||||||
import org.aspectj.lang.annotation.Aspect
|
import org.aspectj.lang.annotation.Aspect
|
||||||
import org.aspectj.lang.annotation.Pointcut
|
import org.aspectj.lang.annotation.Pointcut
|
||||||
import org.aspectj.lang.reflect.MethodSignature
|
import org.aspectj.lang.reflect.MethodSignature
|
||||||
import org.slf4j.MDC
|
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@ -21,19 +22,17 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@Aspect
|
@Aspect
|
||||||
class ControllerLoggingAspect(
|
class ControllerLoggingAspect(
|
||||||
private val messageConverter: ApiLogMessageConverter,
|
private val messageConverter: WebLogMessageConverter,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Pointcut("execution(* roomescape..web..*Controller*.*(..))")
|
@Pointcut("execution(* com.sangdol..web..*Controller*.*(..))")
|
||||||
fun allController() {
|
fun allController() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Around("allController()")
|
@Around("allController()")
|
||||||
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
|
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
|
||||||
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
|
|
||||||
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
|
|
||||||
|
|
||||||
val servletRequest: HttpServletRequest = servletRequest()
|
val servletRequest: HttpServletRequest = servletRequest()
|
||||||
|
val controllerPayload: Map<String, Any> = parseControllerPayload(joinPoint)
|
||||||
|
|
||||||
log.info {
|
log.info {
|
||||||
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
|
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
|
||||||
@ -41,29 +40,22 @@ class ControllerLoggingAspect(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return joinPoint.proceed()
|
return joinPoint.proceed()
|
||||||
.also { logSuccess(servletRequest.getEndpoint(), startTime, it) }
|
.also { logSuccess(servletRequest, it as ResponseEntity<*>) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logSuccess(endpoint: String, startTime: Long, result: Any) {
|
private fun logSuccess(servletRequest: HttpServletRequest, result: ResponseEntity<*>) {
|
||||||
val responseEntity = result as ResponseEntity<*>
|
val body: Any? = if (log.isDebugEnabled()) result.body else null
|
||||||
var convertResponseMessageRequest = ConvertResponseMessageRequest(
|
|
||||||
type = LogType.CONTROLLER_SUCCESS,
|
val logMessage = messageConverter.convertToResponseMessage(
|
||||||
endpoint = endpoint,
|
type = LogType.SUCCEED,
|
||||||
httpStatus = responseEntity.statusCode.value(),
|
servletRequest = servletRequest,
|
||||||
startTime = startTime,
|
httpStatusCode = result.statusCode.value(),
|
||||||
|
responseBody = body,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (log.isDebugEnabled()) {
|
|
||||||
convertResponseMessageRequest = convertResponseMessageRequest.copy(
|
|
||||||
body = responseEntity.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val logMessage = messageConverter.convertToResponseMessage(convertResponseMessageRequest)
|
|
||||||
|
|
||||||
log.info { logMessage }
|
log.info { logMessage }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,14 +63,16 @@ class ControllerLoggingAspect(
|
|||||||
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
|
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parsePayload(joinPoint: JoinPoint): Map<String, Any> {
|
private fun parseControllerPayload(joinPoint: JoinPoint): Map<String, Any> {
|
||||||
val signature = joinPoint.signature as MethodSignature
|
val signature = joinPoint.signature as MethodSignature
|
||||||
val args = joinPoint.args
|
val args = joinPoint.args
|
||||||
val payload = mutableMapOf<String, Any>()
|
val payload = mutableMapOf<String, Any>(
|
||||||
payload["controller_method"] = joinPoint.signature.toShortString()
|
"controller_method" to joinPoint.signature.toShortString()
|
||||||
|
)
|
||||||
|
|
||||||
val requestParams: MutableMap<String, Any> = mutableMapOf()
|
val requestParams: MutableMap<String, Any> = mutableMapOf()
|
||||||
val pathVariables: MutableMap<String, Any> = mutableMapOf()
|
val pathVariables: MutableMap<String, Any> = mutableMapOf()
|
||||||
|
|
||||||
signature.method.parameters.forEachIndexed { index, parameter ->
|
signature.method.parameters.forEachIndexed { index, parameter ->
|
||||||
val arg = args[index]
|
val arg = args[index]
|
||||||
|
|
||||||
@ -93,9 +87,10 @@ class ControllerLoggingAspect(
|
|||||||
parameter.getAnnotation(RequestParam::class.java)?.let {
|
parameter.getAnnotation(RequestParam::class.java)?.let {
|
||||||
requestParams[parameter.name] = arg
|
requestParams[parameter.name] = arg
|
||||||
}
|
}
|
||||||
|
}.also {
|
||||||
|
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
|
||||||
|
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
|
||||||
}
|
}
|
||||||
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
|
|
||||||
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
|
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.sangdol.common.web.asepct
|
||||||
|
|
||||||
|
import io.micrometer.observation.Observation
|
||||||
|
import io.micrometer.observation.ObservationRegistry
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint
|
||||||
|
import org.aspectj.lang.annotation.Around
|
||||||
|
import org.aspectj.lang.annotation.Aspect
|
||||||
|
import org.aspectj.lang.annotation.Pointcut
|
||||||
|
|
||||||
|
@Aspect
|
||||||
|
class ServiceObservationAspect(
|
||||||
|
private val observationRegistry: ObservationRegistry
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
|
||||||
|
fun allServices() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Around("allServices()")
|
||||||
|
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
|
||||||
|
val methodName: String = joinPoint.signature.toShortString()
|
||||||
|
|
||||||
|
return Observation.createNotStarted(methodName, observationRegistry)
|
||||||
|
.observe<Any?> { joinPoint.proceed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.sangdol.common.web.config
|
||||||
|
|
||||||
|
import com.sangdol.common.web.asepct.ServiceObservationAspect
|
||||||
|
import io.micrometer.observation.ObservationPredicate
|
||||||
|
import io.micrometer.observation.ObservationRegistry
|
||||||
|
import io.micrometer.observation.aop.ObservedAspect
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.http.server.observation.ServerRequestObservationContext
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class ObservationConfig(
|
||||||
|
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
|
||||||
|
return ObservedAspect(observationRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
|
||||||
|
return ServiceObservationAspect(observationRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun excludeActuatorPredicate(): ObservationPredicate {
|
||||||
|
return ObservationPredicate { _, context ->
|
||||||
|
if (context !is ServerRequestObservationContext) {
|
||||||
|
return@ObservationPredicate true
|
||||||
|
}
|
||||||
|
|
||||||
|
val servletRequest: HttpServletRequest = context.carrier
|
||||||
|
val requestUri = servletRequest.requestURI
|
||||||
|
|
||||||
|
!requestUri.contains(actuatorPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
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,27 +1,25 @@
|
|||||||
package roomescape.common.exception
|
package com.sangdol.common.web.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.exception.CommonErrorCode
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
import com.sangdol.common.types.web.CommonErrorResponse
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.slf4j.MDC
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import roomescape.common.dto.response.CommonErrorResponse
|
|
||||||
import roomescape.common.log.ApiLogMessageConverter
|
|
||||||
import roomescape.common.log.ConvertResponseMessageRequest
|
|
||||||
import roomescape.common.log.LogType
|
|
||||||
import roomescape.common.log.getEndpoint
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class ExceptionControllerAdvice(
|
class GlobalExceptionHandler(
|
||||||
private val messageConverter: ApiLogMessageConverter
|
private val messageConverter: WebLogMessageConverter
|
||||||
) {
|
) {
|
||||||
@ExceptionHandler(value = [RoomescapeException::class])
|
@ExceptionHandler(value = [RoomescapeException::class])
|
||||||
fun handleRoomException(
|
fun handleRoomException(
|
||||||
@ -32,17 +30,10 @@ class ExceptionControllerAdvice(
|
|||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
|
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
logException(
|
|
||||||
type = type,
|
|
||||||
servletRequest = servletRequest,
|
|
||||||
httpStatus = httpStatus.value(),
|
|
||||||
errorResponse = errorResponse,
|
|
||||||
exception = e
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus)
|
.status(httpStatus.value())
|
||||||
.body(errorResponse)
|
.body(errorResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,29 +42,24 @@ class ExceptionControllerAdvice(
|
|||||||
servletRequest: HttpServletRequest,
|
servletRequest: HttpServletRequest,
|
||||||
e: Exception
|
e: Exception
|
||||||
): ResponseEntity<CommonErrorResponse> {
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
val message: String = if (e is MethodArgumentNotValidException) {
|
if (e is MethodArgumentNotValidException) {
|
||||||
e.bindingResult.allErrors
|
e.bindingResult.allErrors
|
||||||
.mapNotNull { it.defaultMessage }
|
.mapNotNull { it.defaultMessage }
|
||||||
.joinToString(", ")
|
.joinToString(", ")
|
||||||
} else {
|
} else {
|
||||||
e.message!!
|
e.message!!
|
||||||
|
}.also {
|
||||||
|
log.warn { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $it" }
|
||||||
}
|
}
|
||||||
log.debug { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $message" }
|
|
||||||
|
|
||||||
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
|
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
|
||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
logException(
|
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
type = LogType.APPLICATION_FAILURE,
|
|
||||||
servletRequest = servletRequest,
|
|
||||||
httpStatus = httpStatus.value(),
|
|
||||||
errorResponse = errorResponse,
|
|
||||||
exception = e
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus)
|
.status(httpStatus.value())
|
||||||
.body(errorResponse)
|
.body(errorResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,40 +74,26 @@ class ExceptionControllerAdvice(
|
|||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
logException(
|
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
type = LogType.UNHANDLED_EXCEPTION,
|
|
||||||
servletRequest = servletRequest,
|
|
||||||
httpStatus = httpStatus.value(),
|
|
||||||
errorResponse = errorResponse,
|
|
||||||
exception = e
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus)
|
.status(httpStatus.value())
|
||||||
.body(errorResponse)
|
.body(errorResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logException(
|
private fun convertExceptionLogMessage(
|
||||||
type: LogType,
|
|
||||||
servletRequest: HttpServletRequest,
|
servletRequest: HttpServletRequest,
|
||||||
httpStatus: Int,
|
httpStatus: HttpStatus,
|
||||||
errorResponse: CommonErrorResponse,
|
errorResponse: CommonErrorResponse,
|
||||||
exception: Exception
|
exception: Exception
|
||||||
) {
|
): String {
|
||||||
val commonRequest = ConvertResponseMessageRequest(
|
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
|
||||||
type = type,
|
|
||||||
endpoint = servletRequest.getEndpoint(),
|
return messageConverter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
httpStatus = httpStatus,
|
httpStatus = httpStatus,
|
||||||
startTime = MDC.get("startTime")?.toLongOrNull(),
|
responseBody = errorResponse,
|
||||||
body = errorResponse,
|
exception = actualException
|
||||||
)
|
)
|
||||||
|
|
||||||
val logMessage = if (errorResponse.message == exception.message) {
|
|
||||||
messageConverter.convertToResponseMessage(commonRequest)
|
|
||||||
} else {
|
|
||||||
messageConverter.convertToResponseMessage(commonRequest.copy(exception = exception))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn { logMessage }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,20 +1,23 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.common.web.servlet
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||||
|
import com.sangdol.common.utils.MdcStartTimeUtil
|
||||||
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.micrometer.tracing.CurrentTraceContext
|
||||||
import jakarta.servlet.FilterChain
|
import jakarta.servlet.FilterChain
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
import org.slf4j.MDC
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
import org.springframework.web.util.ContentCachingRequestWrapper
|
import org.springframework.web.util.ContentCachingRequestWrapper
|
||||||
import org.springframework.web.util.ContentCachingResponseWrapper
|
import org.springframework.web.util.ContentCachingResponseWrapper
|
||||||
import roomescape.common.util.MdcPrincipalId
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
class HttpRequestLoggingFilter(
|
class HttpRequestLoggingFilter(
|
||||||
private val messageConverter: ApiLogMessageConverter
|
private val messageConverter: WebLogMessageConverter,
|
||||||
|
private val currentTraceContext: CurrentTraceContext
|
||||||
) : OncePerRequestFilter() {
|
) : OncePerRequestFilter() {
|
||||||
override fun doFilterInternal(
|
override fun doFilterInternal(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
@ -26,15 +29,17 @@ class HttpRequestLoggingFilter(
|
|||||||
val cachedRequest = ContentCachingRequestWrapper(request)
|
val cachedRequest = ContentCachingRequestWrapper(request)
|
||||||
val cachedResponse = ContentCachingResponseWrapper(response)
|
val cachedResponse = ContentCachingResponseWrapper(response)
|
||||||
|
|
||||||
val startTime = System.currentTimeMillis()
|
MdcStartTimeUtil.setCurrentTime()
|
||||||
MDC.put("startTime", startTime.toString())
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||||
cachedResponse.copyBodyToResponse()
|
cachedResponse.copyBodyToResponse()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
MDC.remove("startTime")
|
MdcStartTimeUtil.clear()
|
||||||
MdcPrincipalId.clear()
|
MdcPrincipalIdUtil.clear()
|
||||||
|
currentTraceContext.maybeScope(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
package roomescape.common.config
|
package com.sangdol.common.web.config
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.databind.exc.InvalidFormatException
|
import com.fasterxml.jackson.databind.exc.InvalidFormatException
|
||||||
@ -6,11 +6,12 @@ import io.kotest.assertions.throwables.shouldThrow
|
|||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
import java.time.*
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
class JacksonConfigTest(
|
class JacksonConfigTest : FunSpec({
|
||||||
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
|
||||||
) : FunSpec({
|
val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
||||||
|
|
||||||
context("날짜는 yyyy-mm-dd 형식이다.") {
|
context("날짜는 yyyy-mm-dd 형식이다.") {
|
||||||
val date = "2025-07-14"
|
val date = "2025-07-14"
|
||||||
@ -51,38 +52,4 @@ class JacksonConfigTest(
|
|||||||
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
|
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context("Long 타입은 문자열로 (역)직렬화된다.") {
|
|
||||||
val number = 1234567890L
|
|
||||||
val serialized: String = objectMapper.writeValueAsString(number)
|
|
||||||
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
|
|
||||||
|
|
||||||
test("Long 직렬화") {
|
|
||||||
serialized shouldBe "$number"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Long 역직렬화") {
|
|
||||||
deserialized shouldBe number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
|
|
||||||
val date = LocalDate.of(2025, 7, 14)
|
|
||||||
val time = LocalTime.of(12, 30, 0)
|
|
||||||
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
|
|
||||||
val serialized: String = objectMapper.writeValueAsString(dateTime)
|
|
||||||
|
|
||||||
test("OffsetDateTime 직렬화") {
|
|
||||||
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
|
|
||||||
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
|
|
||||||
val serialized: String = objectMapper.writeValueAsString(dateTime)
|
|
||||||
|
|
||||||
test("LocalDateTime 직렬화") {
|
|
||||||
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
14
docker/docker-compose.yaml
Normal file
14
docker/docker-compose.yaml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
mysql-local:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: mysql-local
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "23306:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: init
|
||||||
|
MYSQL_DATABASE: roomescape_local
|
||||||
|
TZ: UTC
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
@ -1,6 +1,18 @@
|
|||||||
node_modules
|
|
||||||
.git
|
.git
|
||||||
.DS_Store
|
.gitignore
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
build
|
||||||
dist
|
dist
|
||||||
build
|
|
||||||
|
# Editor/OS specific
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env*
|
||||||
@ -1,18 +1,17 @@
|
|||||||
# Stage 1: Build the React app
|
FROM node:24-alpine AS builder
|
||||||
FROM node:24 AS builder
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
|
||||||
COPY package-lock.json ./
|
|
||||||
|
|
||||||
RUN npm install --frozen-lockfile
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
# Stage 2: Serve with Nginx
|
|
||||||
FROM nginx:latest
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
frontend/src/api/order/orderAPI.ts
Normal file
12
frontend/src/api/order/orderAPI.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import apiClient from "@_api/apiClient";
|
||||||
|
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
|
||||||
|
|
||||||
|
export const confirm = async (
|
||||||
|
reservationId: string,
|
||||||
|
data: PaymentConfirmRequest,
|
||||||
|
): Promise<void> => {
|
||||||
|
return await apiClient.post<void>(
|
||||||
|
`/orders/${reservationId}/confirm`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
};
|
||||||
5
frontend/src/api/order/orderTypes.ts
Normal file
5
frontend/src/api/order/orderTypes.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface OrderErrorResponse {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
trial: number;
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
paymentType: PaymentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentCancelRequest {
|
export interface PaymentCancelRequest {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
|
||||||
|
|
||||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||||
|
|
||||||
export const ScheduleStatus = {
|
export const ScheduleStatus = {
|
||||||
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startFrom: string;
|
||||||
|
endAt: string;
|
||||||
|
status: ScheduleStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleThemeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleStoreInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleWithStoreAndThemeResponse {
|
||||||
|
schedule: ScheduleResponse,
|
||||||
|
theme: ScheduleThemeInfo,
|
||||||
|
store: ScheduleStoreInfo,
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduleWithThemeResponse {
|
export interface ScheduleWithThemeResponse {
|
||||||
id: string,
|
schedule: ScheduleResponse,
|
||||||
startFrom: string,
|
theme: ScheduleThemeInfo
|
||||||
endAt: string,
|
|
||||||
themeId: string,
|
|
||||||
themeName: string,
|
|
||||||
themeDifficulty: Difficulty,
|
|
||||||
status: ScheduleStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleWithThemeListResponse {
|
export interface ScheduleWithThemeListResponse {
|
||||||
schedules: ScheduleWithThemeResponse[];
|
schedules: ScheduleWithThemeResponse[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Navigate, useLocation} from 'react-router-dom';
|
|
||||||
import {useAuth} from '../context/AuthContext';
|
|
||||||
|
|
||||||
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
|
||||||
const { loggedIn, role, loading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading...</div>; // Or a proper spinner component
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loggedIn) {
|
|
||||||
// Not logged in, redirect to login page. No alert needed here
|
|
||||||
// as the user is simply redirected.
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role !== 'ADMIN') {
|
|
||||||
// Logged in but not an admin, show alert and redirect.
|
|
||||||
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminRoute;
|
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import { 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 {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||||
import {getStores} from '@_api/store/storeAPI';
|
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
import { getStores } from '@_api/store/storeAPI';
|
||||||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
||||||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import { fetchThemeById } from '@_api/theme/themeAPI';
|
||||||
|
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 {type ReservationData} from '@_api/reservation/reservationTypes';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
import {formatDate} from 'src/util/DateTimeFormatter';
|
|
||||||
|
|
||||||
const ReservationStep1Page: React.FC = () => {
|
const ReservationStep1Page: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
fetchSchedules(selectedStore.id, dateStr)
|
fetchSchedules(selectedStore.id, dateStr)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||||
const key = schedule.themeName;
|
const key = schedule.theme.name;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(schedule);
|
acc[key].push(schedule);
|
||||||
return acc;
|
return acc;
|
||||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
const handleConfirmReservation = () => {
|
const handleConfirmReservation = () => {
|
||||||
if (!selectedSchedule) return;
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
holdSchedule(selectedSchedule.id)
|
holdSchedule(selectedSchedule.schedule.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||||
const reservationData: ReservationData = {
|
const reservationData: ReservationData = {
|
||||||
scheduleId: selectedSchedule.id,
|
scheduleId: selectedSchedule.schedule.id,
|
||||||
store: {
|
store: {
|
||||||
id: selectedStore!.id,
|
id: selectedStore!.id,
|
||||||
name: selectedStore!.name,
|
name: selectedStore!.name,
|
||||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
maxParticipants: res.maxParticipants,
|
maxParticipants: res.maxParticipants,
|
||||||
},
|
},
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
startFrom: selectedSchedule.startFrom,
|
startFrom: selectedSchedule.schedule.startFrom,
|
||||||
endAt: selectedSchedule.endAt,
|
endAt: selectedSchedule.schedule.endAt,
|
||||||
};
|
};
|
||||||
navigate('/reservation/form', {state: reservationData});
|
navigate('/reservation/form', {state: reservationData});
|
||||||
}).catch(handleError);
|
}).catch(handleError);
|
||||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<h3>3. 시간 선택</h3>
|
<h3>3. 시간 선택</h3>
|
||||||
<div className="schedule-list">
|
<div className="schedule-list">
|
||||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||||
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
||||||
<div key={themeName} className="theme-schedule-group">
|
<div key={themeName} className="theme-schedule-group">
|
||||||
<div className="theme-header">
|
<div className="theme-header">
|
||||||
<h4>{themeName}</h4>
|
<h4>{themeName}</h4>
|
||||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||||
className="theme-detail-button">상세보기
|
className="theme-detail-button">상세보기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-slots">
|
<div className="time-slots">
|
||||||
{schedules.map(schedule => (
|
{scheduleAndTheme.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.id}
|
key={schedule.schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<div className="modal-section modal-info-grid">
|
<div className="modal-section modal-info-grid">
|
||||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { confirm } from '@_api/order/orderAPI';
|
||||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
import type { OrderErrorResponse } from '@_api/order/orderTypes';
|
||||||
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
|
||||||
if (isLoginRequiredError(err)) {
|
|
||||||
alert('로그인이 필요해요.');
|
|
||||||
navigate('/login', { state: { from: location } });
|
|
||||||
} else {
|
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
|
||||||
alert(message);
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reservationId) {
|
if (!reservationId) {
|
||||||
alert('잘못된 접근입니다.');
|
alert('잘못된 접근입니다.');
|
||||||
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const generateRandomString = () =>
|
const generateRandomString = () =>
|
||||||
crypto.randomUUID().replace(/-/g, '');
|
crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
|
||||||
paymentWidgetRef.current.requestPayment({
|
paymentWidgetRef.current.requestPayment({
|
||||||
orderId: generateRandomString(),
|
orderId: generateRandomString(),
|
||||||
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: totalPrice,
|
amount: totalPrice,
|
||||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
|
||||||
};
|
};
|
||||||
|
confirm(reservationId, paymentData)
|
||||||
confirmPayment(reservationId, paymentData)
|
|
||||||
.then(() => {
|
|
||||||
return confirmReservation(reservationId);
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
alert('결제가 완료되었어요!');
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/reservation/success', {
|
navigate('/reservation/success', {
|
||||||
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(handleError);
|
.catch(err => {
|
||||||
|
const error = err as AxiosError<OrderErrorResponse>;
|
||||||
|
const errorCode = error.response?.data?.code;
|
||||||
|
const errorMessage = error.response?.data?.message;
|
||||||
|
|
||||||
|
if (errorCode === 'B000') {
|
||||||
|
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
|
||||||
|
navigate('/reservation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trial = error.response?.data?.trial || 0;
|
||||||
|
if (trial < 2) {
|
||||||
|
alert(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(errorMessage);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
|
||||||
|
|
||||||
|
if (agreeToOnsitePayment) {
|
||||||
|
confirmReservation(reservationId)
|
||||||
|
.then(() => {
|
||||||
|
navigate('/reservation/success', {
|
||||||
|
state: {
|
||||||
|
storeName,
|
||||||
|
themeName,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
participantCount,
|
||||||
|
totalPrice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
console.error("Payment request error:", error);
|
console.error("Payment request error:", error);
|
||||||
alert("결제 요청 중 오류가 발생했습니다.");
|
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,11 @@ import {
|
|||||||
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
||||||
import {getStores} from '@_api/store/storeAPI';
|
import {getStores} from '@_api/store/storeAPI';
|
||||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||||
import {fetchActiveThemes, fetchThemeById} from '@_api/theme/themeAPI';
|
import {fetchActiveThemes} from '@_api/theme/themeAPI';
|
||||||
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
import {useAdminAuth} from '@_context/AdminAuthContext';
|
||||||
import '@_css/admin-schedule-page.css';
|
import '@_css/admin-schedule-page.css';
|
||||||
|
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
||||||
import React, {Fragment, useEffect, useState} from 'react';
|
import React, {Fragment, useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
@ -53,8 +54,8 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||||||
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false);
|
const [isLoadingThemeDetails] = useState<boolean>(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -332,10 +333,10 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p>
|
<p>
|
||||||
<strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
|
<strong>생성일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
|
<strong>수정일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
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 React, { Fragment, useEffect, useState } from 'react';
|
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import React, {Fragment, useEffect, useState} from 'react';
|
||||||
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
const AdminStorePage: React.FC = () => {
|
const AdminStorePage: React.FC = () => {
|
||||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
||||||
@ -297,10 +298,10 @@ const AdminStorePage: React.FC = () => {
|
|||||||
코드:</strong> {detailedStores[store.id].region.code}
|
코드:</strong> {detailedStores[store.id].region.code}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성일:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
|
<strong>생성일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>수정일:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
|
<strong>수정일:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
<strong>생성자:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
|
||||||
|
|||||||
@ -9,7 +9,8 @@ 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;
|
||||||
@ -256,8 +257,8 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>생성일:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
|
<p><strong>생성일:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
|
||||||
<p><strong>수정일:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
|
<p><strong>수정일:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
|
||||||
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
||||||
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
|||||||
845
query.md
845
query.md
@ -1,845 +0,0 @@
|
|||||||
## Auth
|
|
||||||
|
|
||||||
**로그인**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 회원
|
|
||||||
|
|
||||||
-- 이메일로 회원 조회
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.email = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처로 회원 조회
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.phone = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 회원 추가
|
|
||||||
INSERT INTO users (
|
|
||||||
created_at, created_by, email, name, password, phone, region_code,
|
|
||||||
status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 회원 상태 이력 추가
|
|
||||||
INSERT INTO user_status_history (
|
|
||||||
created_at, created_by, reason, status, updated_at, updated_by,
|
|
||||||
user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payment
|
|
||||||
|
|
||||||
**결제 승인 & 저장**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 결제 정보 추가
|
|
||||||
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
|
|
||||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 결제 상세 정보 추가
|
|
||||||
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
|
|
||||||
) VALUES ( ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
-- 카드 결제 상세 정보 추가
|
|
||||||
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
|
|
||||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**결제 취소**
|
|
||||||
|
|
||||||
SQL
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 ID로 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.approved_at,
|
|
||||||
p.method,
|
|
||||||
p.order_id,
|
|
||||||
p.payment_key,
|
|
||||||
p.requested_at,
|
|
||||||
p.reservation_id,
|
|
||||||
p.status,
|
|
||||||
p.total_amount,
|
|
||||||
p.type
|
|
||||||
FROM
|
|
||||||
payment p
|
|
||||||
WHERE
|
|
||||||
p.reservation_id = ?;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
-- 취소된 결제 정보 추가
|
|
||||||
INSERT INTO canceled_payment (
|
|
||||||
cancel_amount, cancel_reason, canceled_at, canceled_by,
|
|
||||||
card_discount_amount, easypay_discount_amount, payment_id,
|
|
||||||
requested_at, transfer_discount_amount, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Region
|
|
||||||
|
|
||||||
**모든 시/도 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT DISTINCT
|
|
||||||
r.sido_code,
|
|
||||||
r.sido_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
ORDER BY
|
|
||||||
r.sido_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
**시/군/구 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
r.sigungu_code,
|
|
||||||
r.sigungu_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.sido_code = ?
|
|
||||||
GROUP BY
|
|
||||||
r.sigungu_code, r.sigungu_name
|
|
||||||
ORDER BY
|
|
||||||
r.sigungu_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
**지역 코드 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
r.code
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.sido_code = ? AND r.sigungu_code = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reservation
|
|
||||||
|
|
||||||
**Pending 예약 생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- schedule 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- theme 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 예약 추가
|
|
||||||
INSERT INTO reservation (
|
|
||||||
created_at, created_by, participant_count, requirement,
|
|
||||||
reserver_contact, reserver_name, schedule_id, status,
|
|
||||||
updated_at, updated_by, user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**확정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 예약 확정
|
|
||||||
UPDATE
|
|
||||||
reservation
|
|
||||||
SET
|
|
||||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
|
||||||
reserver_name = ?, schedule_id = ?, status = ?,
|
|
||||||
updated_at = ?, updated_by = ?, user_id = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
|
|
||||||
-- Schedule 확정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**취소**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 취소 예약 추가
|
|
||||||
INSERT INTO canceled_reservation (
|
|
||||||
cancel_reason, canceled_at, canceled_by,
|
|
||||||
reservation_id, status, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 예약 취소
|
|
||||||
UPDATE
|
|
||||||
reservation
|
|
||||||
SET
|
|
||||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
|
||||||
reserver_name = ?, schedule_id = ?, status = ?,
|
|
||||||
updated_at = ?, updated_by = ?, user_id = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
|
|
||||||
-- 일정 활성화
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**회원 예약 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.user_id = ? AND r.status IN (?, ?);
|
|
||||||
|
|
||||||
-- 일정 조회 -> 각 예약별 1개씩(N개)
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id
|
|
||||||
JOIN store st ON st.id = s.store_id
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**예약 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 회원 연락처 정보 조회
|
|
||||||
SELECT
|
|
||||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
|
||||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.id = ?;
|
|
||||||
|
|
||||||
-- 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
|
|
||||||
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
|
|
||||||
FROM
|
|
||||||
payment p
|
|
||||||
WHERE
|
|
||||||
p.reservation_id = ?;
|
|
||||||
|
|
||||||
-- 결제 상세 정보 조회
|
|
||||||
SELECT
|
|
||||||
pd.id,
|
|
||||||
CASE
|
|
||||||
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
|
|
||||||
WHEN pcd.id IS NOT NULL THEN 2 -- card
|
|
||||||
WHEN pep.id IS NOT NULL THEN 3 -- easypay
|
|
||||||
WHEN pd.id IS NOT NULL THEN 0 -- etc
|
|
||||||
END AS payment_type,
|
|
||||||
pd.payment_id, pd.supplied_amount, pd.vat,
|
|
||||||
pbt.bank_code, pbt.settlement_status,
|
|
||||||
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
|
|
||||||
pcd.easypay_discount_amount, pcd.easypay_provider_code,
|
|
||||||
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
|
|
||||||
pcd.owner_type,
|
|
||||||
pep.amount AS easypay_amount,
|
|
||||||
pep.discount_amount AS easypay_discount_amount,
|
|
||||||
pep.easypay_provider_code AS easypay_provider
|
|
||||||
FROM
|
|
||||||
payment_detail pd
|
|
||||||
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
|
|
||||||
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
|
|
||||||
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
|
|
||||||
WHERE
|
|
||||||
pd.payment_id = ?;
|
|
||||||
|
|
||||||
-- 취소 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
|
|
||||||
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
|
|
||||||
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
|
|
||||||
FROM
|
|
||||||
canceled_payment cp
|
|
||||||
WHERE
|
|
||||||
cp.payment_id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schedule
|
|
||||||
|
|
||||||
**날짜, 시간, 테마로 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
|
|
||||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
|
||||||
WHERE
|
|
||||||
s.date = ?
|
|
||||||
```
|
|
||||||
|
|
||||||
**감사 정보 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 작업자 조회(createdBy, updatedBy)
|
|
||||||
SELECT
|
|
||||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
|
||||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
|
||||||
a.updated_by
|
|
||||||
FROM
|
|
||||||
admin a
|
|
||||||
WHERE
|
|
||||||
a.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM schedule s
|
|
||||||
WHERE
|
|
||||||
s.store_id = ?
|
|
||||||
AND s.date = ?
|
|
||||||
AND s.theme_id = ?
|
|
||||||
AND s.time = ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
|
|
||||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
|
||||||
WHERE
|
|
||||||
s.date = ?
|
|
||||||
|
|
||||||
-- 일정 추가
|
|
||||||
INSERT INTO schedule (
|
|
||||||
created_at, created_by, date, status, store_id,
|
|
||||||
theme_id, time, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 삭제**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 삭제
|
|
||||||
DELETE FROM schedule
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**상태 → HOLD 변경**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store
|
|
||||||
|
|
||||||
**매장 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 지역 정보 조회
|
|
||||||
SELECT
|
|
||||||
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.code = ?;
|
|
||||||
|
|
||||||
-- 감사 정보 조회(createdBy, updatedBy)
|
|
||||||
SELECT
|
|
||||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
|
||||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
|
||||||
a.updated_by
|
|
||||||
FROM
|
|
||||||
admin a
|
|
||||||
WHERE
|
|
||||||
a.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**매장 등록**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이름 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 주소 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 사업자번호 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO store (
|
|
||||||
address, business_reg_num, contact, created_at, created_by,
|
|
||||||
name, region_code, status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**매장 수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
store
|
|
||||||
SET
|
|
||||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
|
||||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**비활성화(status = DISABLE)**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
store
|
|
||||||
SET
|
|
||||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
|
||||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**모든 매장 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.status = 'ACTIVE'
|
|
||||||
AND (? IS NULL OR s.region_code LIKE ?);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개별 매장 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Theme
|
|
||||||
|
|
||||||
**생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이름으로 조회
|
|
||||||
SELECT
|
|
||||||
t.id
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.name = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO theme (
|
|
||||||
available_minutes, created_at, created_by, description, difficulty,
|
|
||||||
expected_minutes_from, expected_minutes_to, is_active, max_participants,
|
|
||||||
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Active인 모든 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.is_active = TRUE;
|
|
||||||
```
|
|
||||||
|
|
||||||
**테마 목록 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t;
|
|
||||||
```
|
|
||||||
|
|
||||||
**감사 정보 포함 개별 테마 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**개별 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**삭제**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 삭제
|
|
||||||
DELETE FROM theme WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
theme
|
|
||||||
SET
|
|
||||||
available_minutes = ?, description = ?, difficulty = ?,
|
|
||||||
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
|
|
||||||
max_participants = ?, min_participants = ?, name = ?, price = ?,
|
|
||||||
thumbnail_url = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**인기 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
|
|
||||||
t.min_participants, t.max_participants,
|
|
||||||
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
JOIN (
|
|
||||||
SELECT
|
|
||||||
s.theme_id, count(*) as reservation_count
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN
|
|
||||||
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
|
|
||||||
WHERE
|
|
||||||
s.status = 'RESERVED'
|
|
||||||
AND (s.date BETWEEN :startFrom AND :endAt)
|
|
||||||
GROUP BY
|
|
||||||
s.theme_id
|
|
||||||
ORDER BY
|
|
||||||
reservation_count desc
|
|
||||||
LIMIT :count
|
|
||||||
) ranked_themes ON t.id = ranked_themes.theme_id
|
|
||||||
```
|
|
||||||
|
|
||||||
### User
|
|
||||||
|
|
||||||
**회원가입**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이메일 중복 확인
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.email = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처 중복 확인
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.phone = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO users (
|
|
||||||
created_at, created_by, email, name, password, phone, region_code,
|
|
||||||
status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 상태 변경 이력 추가
|
|
||||||
INSERT INTO user_status_history (
|
|
||||||
created_at, created_by, reason, status, updated_at, updated_by,
|
|
||||||
user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**연락처 정보 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
|
||||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.id = ?;
|
|
||||||
```
|
|
||||||
57
service/build.gradle.kts
Normal file
57
service/build.gradle.kts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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,8 +1,8 @@
|
|||||||
package roomescape.admin.exception
|
package com.sangdol.roomescape.admin.exception
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
import roomescape.common.exception.ErrorCode
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
import roomescape.common.exception.RoomescapeException
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
class AdminException(
|
class AdminException(
|
||||||
override val errorCode: AdminErrorCode,
|
override val errorCode: AdminErrorCode,
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package roomescape.admin.infrastructure.persistence
|
package com.sangdol.roomescape.admin.infrastructure.persistence
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.AuditingBaseEntity
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import roomescape.common.entity.AuditingBaseEntity
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "admin")
|
@Table(name = "admin")
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.admin.infrastructure.persistence
|
package com.sangdol.roomescape.admin.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.admin.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
||||||
|
|
||||||
|
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
||||||
|
id = this.id,
|
||||||
|
password = this.password,
|
||||||
|
name = this.name,
|
||||||
|
type = this.type,
|
||||||
|
storeId = this.storeId,
|
||||||
|
permissionLevel = this.permissionLevel
|
||||||
|
)
|
||||||
@ -1,19 +1,20 @@
|
|||||||
package roomescape.auth.business
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import roomescape.admin.business.AdminService
|
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
|
||||||
import roomescape.auth.web.LoginContext
|
|
||||||
import roomescape.auth.web.LoginRequest
|
|
||||||
import roomescape.auth.web.LoginSuccessResponse
|
|
||||||
import roomescape.common.dto.LoginCredentials
|
|
||||||
import roomescape.common.dto.PrincipalType
|
|
||||||
import roomescape.user.business.UserService
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -25,39 +26,40 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
|||||||
class AuthService(
|
class AuthService(
|
||||||
private val adminService: AdminService,
|
private val adminService: AdminService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val loginHistoryService: LoginHistoryService,
|
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun login(
|
fun login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
context: LoginContext
|
context: LoginContext
|
||||||
): LoginSuccessResponse {
|
): LoginSuccessResponse {
|
||||||
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||||
val (credentials, extraClaims) = getCredentials(request)
|
val (credentials, extraClaims) = getCredentials(request)
|
||||||
|
|
||||||
|
val event = LoginHistoryEvent(
|
||||||
|
id = credentials.id,
|
||||||
|
type = request.principalType,
|
||||||
|
ipAddress = context.ipAddress,
|
||||||
|
userAgent = context.userAgent
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
verifyPasswordOrThrow(request, credentials)
|
verifyPasswordOrThrow(request, credentials)
|
||||||
|
|
||||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||||
|
|
||||||
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onSuccess())
|
||||||
|
|
||||||
return credentials.toResponse(accessToken).also {
|
return credentials.toResponse(accessToken).also {
|
||||||
log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" }
|
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onFailure())
|
||||||
|
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> {
|
is AuthException -> { throw e }
|
||||||
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,7 +71,7 @@ class AuthService(
|
|||||||
credentials: LoginCredentials
|
credentials: LoginCredentials
|
||||||
) {
|
) {
|
||||||
if (credentials.password != request.password) {
|
if (credentials.password != request.password) {
|
||||||
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||||
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.mapper.toEntity
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LoginHistoryEventListener(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val loginHistoryRepository: LoginHistoryRepository,
|
||||||
|
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
|
||||||
|
private var batchSize: Int = 0
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(classes = [LoginHistoryEvent::class])
|
||||||
|
fun onLoginCompleted(event: LoginHistoryEvent) {
|
||||||
|
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
|
||||||
|
|
||||||
|
queue.add(event.toEntity(idGenerator.create())).also {
|
||||||
|
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.size >= batchSize) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
|
||||||
|
fun flushScheduled() {
|
||||||
|
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun flushAll() {
|
||||||
|
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flush() {
|
||||||
|
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
val batch = mutableListOf<LoginHistoryEntity>()
|
||||||
|
repeat(batchSize) {
|
||||||
|
val entity: LoginHistoryEntity? = queue.poll()
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
batch.add(entity)
|
||||||
|
} else {
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginHistoryRepository.saveAll(batch).also {
|
||||||
|
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
class LoginHistoryEvent(
|
||||||
|
val id: Long,
|
||||||
|
val type: PrincipalType,
|
||||||
|
var success: Boolean = true,
|
||||||
|
val ipAddress: String,
|
||||||
|
val userAgent: String
|
||||||
|
) {
|
||||||
|
fun onSuccess(): LoginHistoryEvent {
|
||||||
|
this.success = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFailure(): LoginHistoryEvent {
|
||||||
|
this.success = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
enum class PrincipalType {
|
||||||
|
USER, ADMIN
|
||||||
|
}
|
||||||
@ -1,5 +1,11 @@
|
|||||||
package roomescape.auth.docs
|
package com.sangdol.roomescape.auth.docs
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
@ -8,12 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
|
|||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import roomescape.auth.web.LoginRequest
|
|
||||||
import roomescape.auth.web.LoginSuccessResponse
|
|
||||||
import roomescape.auth.web.support.Public
|
|
||||||
import roomescape.auth.web.support.User
|
|
||||||
import roomescape.common.dto.CurrentUserContext
|
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
|
||||||
|
|
||||||
interface AuthAPI {
|
interface AuthAPI {
|
||||||
|
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
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,7 +1,7 @@
|
|||||||
package roomescape.auth.exception
|
package com.sangdol.roomescape.auth.exception
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
import roomescape.common.exception.ErrorCode
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
enum class AuthErrorCode(
|
enum class AuthErrorCode(
|
||||||
override val httpStatus: HttpStatus,
|
override val httpStatus: HttpStatus,
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package roomescape.auth.exception
|
package com.sangdol.roomescape.auth.exception
|
||||||
|
|
||||||
import roomescape.common.exception.RoomescapeException
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
|
||||||
class AuthException(
|
class AuthException(
|
||||||
override val errorCode: AuthErrorCode,
|
override val errorCode: AuthErrorCode,
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package roomescape.auth.infrastructure.jwt
|
package com.sangdol.roomescape.auth.infrastructure.jwt
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.jsonwebtoken.Claims
|
import io.jsonwebtoken.Claims
|
||||||
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
|
|||||||
import io.jsonwebtoken.security.Keys
|
import io.jsonwebtoken.security.Keys
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ class JwtUtils(
|
|||||||
val claims = extractAllClaims(token)
|
val claims = extractAllClaims(token)
|
||||||
|
|
||||||
return claims.subject ?: run {
|
return claims.subject ?: run {
|
||||||
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package roomescape.auth.infrastructure.persistence
|
package com.sangdol.roomescape.auth.infrastructure.persistence
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import roomescape.common.dto.PrincipalType
|
import java.time.Instant
|
||||||
import roomescape.common.entity.PersistableBaseEntity
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "login_history")
|
@Table(name = "login_history")
|
||||||
@ -24,5 +24,5 @@ class LoginHistoryEntity(
|
|||||||
|
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
) : PersistableBaseEntity(id)
|
) : PersistableBaseEntity(id)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.auth.infrastructure.persistence
|
package com.sangdol.roomescape.auth.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.auth.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
|
||||||
|
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
|
||||||
|
id = id,
|
||||||
|
principalId = this.id,
|
||||||
|
principalType = this.type,
|
||||||
|
success = this.success,
|
||||||
|
ipAddress = this.ipAddress,
|
||||||
|
userAgent = this.userAgent
|
||||||
|
)
|
||||||
@ -1,16 +1,19 @@
|
|||||||
package roomescape.auth.web
|
package com.sangdol.roomescape.auth.web
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.auth.business.AuthService
|
||||||
|
import com.sangdol.roomescape.auth.docs.AuthAPI
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import roomescape.auth.business.AuthService
|
|
||||||
import roomescape.auth.docs.AuthAPI
|
|
||||||
import roomescape.auth.web.support.User
|
|
||||||
import roomescape.common.dto.CurrentUserContext
|
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/auth")
|
@RequestMapping("/auth")
|
||||||
@ -36,3 +39,8 @@ class AuthController(
|
|||||||
return ResponseEntity.ok().build()
|
return ResponseEntity.ok().build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||||
|
ipAddress = this.remoteAddr,
|
||||||
|
userAgent = this.getHeader("User-Agent")
|
||||||
|
)
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package roomescape.auth.web.support
|
package com.sangdol.roomescape.auth.web.support
|
||||||
|
|
||||||
import roomescape.admin.infrastructure.persistence.AdminType
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||||
import roomescape.admin.infrastructure.persistence.Privilege
|
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
||||||
|
|
||||||
@Target(AnnotationTarget.FUNCTION)
|
@Target(AnnotationTarget.FUNCTION)
|
||||||
@Retention(AnnotationRetention.RUNTIME)
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.auth.web.support
|
package com.sangdol.roomescape.auth.web.support
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@ -1,5 +1,16 @@
|
|||||||
package roomescape.auth.web.support.interceptors
|
package com.sangdol.roomescape.auth.web.support.interceptors
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
||||||
|
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||||
|
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
|
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
||||||
|
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -7,17 +18,6 @@ import jakarta.servlet.http.HttpServletResponse
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.method.HandlerMethod
|
import org.springframework.web.method.HandlerMethod
|
||||||
import org.springframework.web.servlet.HandlerInterceptor
|
import org.springframework.web.servlet.HandlerInterceptor
|
||||||
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
|
||||||
import roomescape.admin.infrastructure.persistence.AdminType
|
|
||||||
import roomescape.admin.infrastructure.persistence.Privilege
|
|
||||||
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
|
||||||
import roomescape.auth.business.CLAIM_PERMISSION_KEY
|
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
|
||||||
import roomescape.auth.web.support.AdminOnly
|
|
||||||
import roomescape.auth.web.support.accessToken
|
|
||||||
import roomescape.common.util.MdcPrincipalId
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ class AdminInterceptor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
run {
|
run {
|
||||||
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
|
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
|
||||||
val type: AdminType = validateTypeAndGet(token, annotation.type)
|
val type: AdminType = validateTypeAndGet(token, annotation.type)
|
||||||
val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
|
val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
|
||||||
|
|
||||||
@ -47,7 +47,10 @@ class AdminInterceptor(
|
|||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> { throw e }
|
is AuthException -> {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
|
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
|
||||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||||
@ -1,5 +1,12 @@
|
|||||||
package roomescape.auth.web.support.interceptors
|
package com.sangdol.roomescape.auth.web.support.interceptors
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||||
|
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
|
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -7,13 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.method.HandlerMethod
|
import org.springframework.web.method.HandlerMethod
|
||||||
import org.springframework.web.servlet.HandlerInterceptor
|
import org.springframework.web.servlet.HandlerInterceptor
|
||||||
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
|
||||||
import roomescape.auth.web.support.UserOnly
|
|
||||||
import roomescape.auth.web.support.accessToken
|
|
||||||
import roomescape.common.util.MdcPrincipalId
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ class UserInterceptor(
|
|||||||
val token: String? = request.accessToken()
|
val token: String? = request.accessToken()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
|
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLAIM_ADMIN_TYPE_KEY 가 존재하면 관리자 토큰임
|
* CLAIM_ADMIN_TYPE_KEY 가 존재하면 관리자 토큰임
|
||||||
@ -47,7 +47,10 @@ class UserInterceptor(
|
|||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> { throw e }
|
is AuthException -> {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
|
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
|
||||||
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
|
||||||
@ -1,5 +1,12 @@
|
|||||||
package roomescape.auth.web.support.resolver
|
package com.sangdol.roomescape.auth.web.support.resolver
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
|
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||||
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -9,19 +16,12 @@ import org.springframework.web.bind.support.WebDataBinderFactory
|
|||||||
import org.springframework.web.context.request.NativeWebRequest
|
import org.springframework.web.context.request.NativeWebRequest
|
||||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||||
import org.springframework.web.method.support.ModelAndViewContainer
|
import org.springframework.web.method.support.ModelAndViewContainer
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
|
||||||
import roomescape.auth.exception.AuthException
|
|
||||||
import roomescape.auth.infrastructure.jwt.JwtUtils
|
|
||||||
import roomescape.auth.web.support.User
|
|
||||||
import roomescape.auth.web.support.accessToken
|
|
||||||
import roomescape.user.business.UserService
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class UserContextResolver(
|
class UserContextResolver(
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
private val userService: UserService,
|
|
||||||
) : HandlerMethodArgumentResolver {
|
) : HandlerMethodArgumentResolver {
|
||||||
|
|
||||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||||
@ -38,9 +38,11 @@ class UserContextResolver(
|
|||||||
val token: String? = request.accessToken()
|
val token: String? = request.accessToken()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val id: Long = jwtUtils.extractSubject(token).toLong()
|
val id: Long = jwtUtils.extractSubject(token).also {
|
||||||
|
MdcPrincipalIdUtil.set(it)
|
||||||
|
}.toLong()
|
||||||
|
|
||||||
return userService.findContextById(id)
|
return CurrentUserContext(id = id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
||||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.sangdol.roomescape.common.config
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("!deploy & local")
|
||||||
|
class LocalDatabaseCleaner(
|
||||||
|
private val jdbcTemplate: JdbcTemplate
|
||||||
|
) {
|
||||||
|
@PreDestroy
|
||||||
|
@Transactional
|
||||||
|
fun clearAll() {
|
||||||
|
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" }
|
||||||
|
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||||
|
|
||||||
|
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
|
||||||
|
rs.getString(1).lowercase()
|
||||||
|
}.forEach {
|
||||||
|
jdbcTemplate.execute("TRUNCATE TABLE $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||||
|
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
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,8 +1,7 @@
|
|||||||
package roomescape.common.log
|
package com.sangdol.roomescape.common.config
|
||||||
|
|
||||||
|
import com.sangdol.common.log.sql.SlowQueryDataSourceFactory
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
|
|
||||||
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
@ -10,11 +9,9 @@ import org.springframework.boot.jdbc.DataSourceBuilder
|
|||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.context.annotation.Primary
|
import org.springframework.context.annotation.Primary
|
||||||
import org.springframework.context.annotation.Profile
|
|
||||||
import javax.sql.DataSource
|
import javax.sql.DataSource
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@Profile("deploy")
|
|
||||||
@EnableConfigurationProperties(SlowQueryProperties::class)
|
@EnableConfigurationProperties(SlowQueryProperties::class)
|
||||||
class ProxyDataSourceConfig {
|
class ProxyDataSourceConfig {
|
||||||
|
|
||||||
@ -23,15 +20,12 @@ class ProxyDataSourceConfig {
|
|||||||
fun dataSource(
|
fun dataSource(
|
||||||
@Qualifier("actualDataSource") actualDataSource: DataSource,
|
@Qualifier("actualDataSource") actualDataSource: DataSource,
|
||||||
properties: SlowQueryProperties
|
properties: SlowQueryProperties
|
||||||
): DataSource = ProxyDataSourceBuilder.create(actualDataSource)
|
): DataSource = SlowQueryDataSourceFactory.create(
|
||||||
.name(properties.loggerName)
|
dataSource = actualDataSource,
|
||||||
.listener(
|
loggerName = properties.loggerName,
|
||||||
MDCAwareSlowQueryListenerWithoutParams(
|
logLevel = properties.logLevel,
|
||||||
logLevel = SLF4JLogLevel.nullSafeValueOf(properties.logLevel.uppercase()),
|
thresholdMs = properties.thresholdMs
|
||||||
thresholdMs = properties.thresholdMs
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
.buildProxy()
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConfigurationProperties(prefix = "spring.datasource.hikari")
|
@ConfigurationProperties(prefix = "spring.datasource.hikari")
|
||||||
@ -40,7 +34,6 @@ class ProxyDataSourceConfig {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Profile("deploy")
|
|
||||||
@ConfigurationProperties(prefix = "slow-query")
|
@ConfigurationProperties(prefix = "slow-query")
|
||||||
data class SlowQueryProperties(
|
data class SlowQueryProperties(
|
||||||
val loggerName: String,
|
val loggerName: String,
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.sangdol.roomescape.common.config
|
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationPredicate
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class TraceConfig {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val scheduleTaskName = "tasks.scheduled.execution"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun excludeSchedulerPredicate(): ObservationPredicate {
|
||||||
|
return ObservationPredicate { name, context ->
|
||||||
|
!name.equals(scheduleTaskName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
package roomescape.common.config
|
package com.sangdol.roomescape.common.config
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
|
||||||
|
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
|
||||||
|
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
import roomescape.auth.web.support.interceptors.AdminInterceptor
|
|
||||||
import roomescape.auth.web.support.interceptors.UserInterceptor
|
|
||||||
import roomescape.auth.web.support.resolver.UserContextResolver
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class WebMvcConfig(
|
class WebMvcConfig(
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.sangdol.roomescape.common.types
|
||||||
|
|
||||||
|
data class CurrentUserContext(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderException
|
||||||
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
|
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class OrderService(
|
||||||
|
private val reservationService: ReservationService,
|
||||||
|
private val scheduleService: ScheduleService,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
|
private val orderValidator: OrderValidator,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||||
|
val paymentKey = paymentConfirmRequest.paymentKey
|
||||||
|
|
||||||
|
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
try {
|
||||||
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
|
validateCanConfirm(reservationId)
|
||||||
|
reservationService.markInProgress(reservationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
|
||||||
|
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
|
||||||
|
|
||||||
|
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||||
|
e.errorCode
|
||||||
|
} else {
|
||||||
|
OrderErrorCode.ORDER_UNEXPECTED_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
throw OrderException(errorCode, e.message ?: errorCode.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCanConfirm(reservationId: Long) {
|
||||||
|
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||||
|
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||||
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
orderValidator.validateCanConfirm(reservation, schedule)
|
||||||
|
} catch (e: OrderException) {
|
||||||
|
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
|
throw OrderException(errorCode, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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