[#48] Tosspay mocking 서버 구현을 위한 멀티모듈 전환 (#49)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Tosspay mocking 서버를 해당 프로젝트 내 구현할 때 각 서비스간 구분이 수월하도록 모듈 분리
- 분리하는 과정에서 추후 공통적으로 활용될 수 있다고 판단한 기능들은 common 모듈로 분리
- 일부 테스트 보완

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
<img width="612" alt="스크린샷 2025-09-29 22.05.25.png" src="attachments/d401a48a-3dd5-4ced-9315-b9d8aff16bf9">

- 전체 테스트 후 클래스 커버리지 기준 100% 달성 확인

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

Reviewed-on: #49
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-09-30 00:39:13 +00:00 committed by 이상진
parent 32b8019576
commit 97c3e1598c
203 changed files with 2383 additions and 1608 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
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")
implementation(project(":common:utils"))
implementation(project(":common:types"))
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -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.LocalDateTime
import kotlin.jvm.Transient
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
@ -31,23 +31,3 @@ abstract class AuditingBaseEntity(
@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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
implementation(project(":common:log"))
implementation(project(":common:utils"))
implementation(project(":common:types"))
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

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

View File

@ -1,4 +1,4 @@
package roomescape.common.config package com.sangdol.common.web.config
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
@ -14,7 +14,11 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.time.* import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Configuration @Configuration
@ -23,6 +27,9 @@ class JacksonConfig {
companion object { companion object {
private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter = private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX") DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm")
} }
@Bean @Bean
@ -43,11 +50,11 @@ class JacksonConfig {
) )
.addSerializer( .addSerializer(
LocalTime::class.java, LocalTime::class.java,
LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeSerializer(LOCAL_TIME_FORMATTER)
) )
.addDeserializer( .addDeserializer(
LocalTime::class.java, LocalTime::class.java,
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule ) as JavaTimeModule
private fun dateTimeModule(): SimpleModule { private fun dateTimeModule(): SimpleModule {
@ -80,4 +87,4 @@ class JacksonConfig {
gen.writeString(value.format(ISO_OFFSET_DATE_TIME_FORMATTER)) gen.writeString(value.format(ISO_OFFSET_DATE_TIME_FORMATTER))
} }
} }
} }

View File

@ -1,6 +1,9 @@
package roomescape.common.log package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.ObjectMapper 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 org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@ -9,27 +12,27 @@ import org.springframework.core.Ordered
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
@Configuration @Configuration
class LogConfiguration { class WebLoggingConfig {
@Bean @Bean
@DependsOn(value = ["apiLogMessageConverter"]) @DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean( fun filterRegistrationBean(
apiLogMessageConverter: ApiLogMessageConverter webLogMessageConverter: WebLogMessageConverter
): FilterRegistrationBean<OncePerRequestFilter> { ): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(apiLogMessageConverter) val filter = HttpRequestLoggingFilter(webLogMessageConverter)
return FilterRegistrationBean<OncePerRequestFilter>(filter) return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 } .apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
} }
@Bean @Bean
@DependsOn(value = ["apiLogMessageConverter"]) @DependsOn(value = ["webLogMessageConverter"])
fun apiLoggingAspect(apiLogMessageConverter: ApiLogMessageConverter): ControllerLoggingAspect { fun apiLoggingAspect(webLogMessageConverter: WebLogMessageConverter): ControllerLoggingAspect {
return ControllerLoggingAspect(apiLogMessageConverter) return ControllerLoggingAspect(webLogMessageConverter)
} }
@Bean @Bean
fun apiLogMessageConverter(objectMapper: ObjectMapper): ApiLogMessageConverter { fun webLogMessageConverter(objectMapper: ObjectMapper): WebLogMessageConverter {
return ApiLogMessageConverter(objectMapper) return WebLogMessageConverter(objectMapper)
} }
} }

View File

@ -1,27 +1,26 @@
package roomescape.common.exception package com.sangdol.common.web.exception
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.exception.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 +31,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 logException(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 +43,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( logException(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,39 +75,29 @@ class ExceptionControllerAdvice(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException( logException(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 logException(
type: LogType,
servletRequest: HttpServletRequest, servletRequest: HttpServletRequest,
httpStatus: Int, httpStatus: HttpStatus,
errorResponse: CommonErrorResponse, errorResponse: CommonErrorResponse,
exception: Exception exception: Exception
) { ) {
val commonRequest = ConvertResponseMessageRequest( val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
type = type, val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
endpoint = servletRequest.getEndpoint(),
httpStatus = httpStatus,
startTime = MDC.get("startTime")?.toLongOrNull(),
body = errorResponse,
)
val logMessage = if (errorResponse.message == exception.message) { val logMessage = messageConverter.convertToResponseMessage(
messageConverter.convertToResponseMessage(commonRequest) type = type,
} else { servletRequest = servletRequest,
messageConverter.convertToResponseMessage(commonRequest.copy(exception = exception)) httpStatusCode = httpStatus.value(),
} responseBody = errorResponse,
exception = actualException
)
log.warn { logMessage } log.warn { logMessage }
} }

View File

@ -1,20 +1,21 @@
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 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
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
@ -26,15 +27,14 @@ 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()
} finally { } finally {
MDC.remove("startTime") MdcStartTimeUtil.clear()
MdcPrincipalId.clear() MdcPrincipalIdUtil.clear()
} }
} }
} }

View File

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

View File

@ -0,0 +1,49 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType
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)
}
}

View File

@ -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,15 @@ 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.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
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"
@ -85,4 +89,4 @@ class JacksonConfigTest(
serialized shouldBe "\"2025-07-14T12:30:00+09:00\"" serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
} }
} }
}) })

View File

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

View File

@ -0,0 +1,166 @@
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
}
}
}
})

63
service/build.gradle.kts Normal file
View File

@ -0,0 +1,63 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("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")
// 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:utils"))
implementation(project(":common:types"))
implementation(project(":common:log"))
implementation(project(":common:web"))
}
tasks.named<Jar>("jar") {
enabled = false
}

View File

@ -1,10 +1,12 @@
package roomescape package com.sangdol.roomescape
import org.springframework.boot.Banner import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication @SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
)
class RoomescapeApplication class RoomescapeApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {

View File

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

View File

@ -0,0 +1,39 @@
package com.sangdol.roomescape.admin.business.dto
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.web.LoginCredentials
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
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
)
}
fun AdminEntity.toCredentials() = AdminLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
type = this.type,
storeId = this.storeId,
permissionLevel = this.permissionLevel
)
data class AdminLoginSuccessResponse(
override val accessToken: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

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

View File

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

View File

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

View File

@ -1,19 +1,15 @@
package roomescape.auth.business package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.*
import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import 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 {}

View File

@ -1,23 +1,22 @@
package roomescape.auth.business package com.sangdol.roomescape.auth.business
import com.github.f4b6a3.tsid.TsidFactory import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.web.LoginContext
import com.sangdol.roomescape.auth.web.PrincipalType
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import roomescape.auth.web.LoginContext
import roomescape.common.config.next
import roomescape.common.dto.PrincipalType
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class LoginHistoryService( class LoginHistoryService(
private val loginHistoryRepository: LoginHistoryRepository, private val loginHistoryRepository: LoginHistoryRepository,
private val tsidFactory: TsidFactory, private val idGenerator: IDGenerator,
) { ) {
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
fun createSuccessHistory( fun createSuccessHistory(
@ -47,7 +46,7 @@ class LoginHistoryService(
runCatching { runCatching {
LoginHistoryEntity( LoginHistoryEntity(
id = tsidFactory.next(), id = idGenerator.create(),
principalId = principalId, principalId = principalId,
principalType = principalType, principalType = principalType,
success = success, success = success,

View File

@ -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.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.LoginRequest
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User
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 {

View File

@ -1,7 +1,7 @@
package roomescape.auth.exception package com.sangdol.roomescape.auth.exception
import org.springframework.http.HttpStatus import com.sangdol.common.types.web.HttpStatus
import roomescape.common.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
enum class AuthErrorCode( enum class AuthErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

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

View File

@ -1,4 +1,4 @@
package roomescape.auth.infrastructure.jwt package com.sangdol.roomescape.auth.infrastructure.jwt
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -8,8 +8,8 @@ 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 com.sangdol.roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey

View File

@ -1,10 +1,10 @@
package roomescape.auth.infrastructure.persistence package com.sangdol.roomescape.auth.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.auth.web.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 roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime import java.time.LocalDateTime
@Entity @Entity

View File

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

View File

@ -1,16 +1,16 @@
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.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")

View File

@ -1,8 +1,11 @@
package roomescape.auth.web package com.sangdol.roomescape.auth.web
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.common.dto.PrincipalType enum class PrincipalType {
USER, ADMIN
}
data class LoginContext( data class LoginContext(
val ipAddress: String, val ipAddress: String,
@ -25,14 +28,10 @@ abstract class LoginSuccessResponse {
abstract val name: String abstract val name: String
} }
data class UserLoginSuccessResponse( abstract class LoginCredentials {
override val accessToken: String, abstract val id: Long
override val name: String, abstract val password: String
) : LoginSuccessResponse() abstract val name: String
data class AdminLoginSuccessResponse( abstract fun toResponse(accessToken: String): LoginSuccessResponse
override val accessToken: String, }
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package roomescape.auth.web.support.interceptors package com.sangdol.roomescape.auth.web.support.interceptors
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -7,17 +7,17 @@ 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 com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
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
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.business.CLAIM_PERMISSION_KEY import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
import roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.accessToken import com.sangdol.roomescape.auth.web.support.accessToken
import roomescape.common.util.MdcPrincipalId import com.sangdol.common.utils.MdcPrincipalIdUtil
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)

View File

@ -1,4 +1,4 @@
package roomescape.auth.web.support.interceptors package com.sangdol.roomescape.auth.web.support.interceptors
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -7,13 +7,13 @@ 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 com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import roomescape.auth.web.support.accessToken import com.sangdol.roomescape.auth.web.support.accessToken
import roomescape.common.util.MdcPrincipalId import com.sangdol.common.utils.MdcPrincipalIdUtil
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 존재하면 관리자 토큰임

View File

@ -1,4 +1,4 @@
package roomescape.auth.web.support.resolver package com.sangdol.roomescape.auth.web.support.resolver
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -9,12 +9,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 com.sangdol.roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import roomescape.auth.web.support.accessToken import com.sangdol.roomescape.auth.web.support.accessToken
import roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

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

View File

@ -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
@ -23,15 +22,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")

View File

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

View File

@ -1,12 +1,12 @@
package roomescape.common.config package com.sangdol.roomescape.common.config
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 com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
import roomescape.auth.web.support.interceptors.UserInterceptor import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
import roomescape.auth.web.support.resolver.UserContextResolver import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(

View File

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

View File

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

View File

@ -1,17 +1,17 @@
package roomescape.payment.business package com.sangdol.roomescape.payment.business
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.util.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentClientCancelResponse import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import roomescape.payment.infrastructure.client.PaymentClientConfirmResponse import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import roomescape.payment.web.* import com.sangdol.roomescape.payment.web.*
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -41,6 +41,9 @@ class PaymentService(
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id) val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
} ?: run {
log.warn { "[PaymentService.confirm] 결제 확정 중 예상치 못한 null 반환" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
} }
} }

View File

@ -1,16 +1,15 @@
package roomescape.payment.business package com.sangdol.roomescape.payment.business
import com.github.f4b6a3.tsid.TsidFactory import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.*
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
import com.sangdol.roomescape.payment.infrastructure.persistence.*
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.*
import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.common.PaymentType
import roomescape.payment.infrastructure.persistence.*
import java.time.LocalDateTime import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -20,7 +19,7 @@ class PaymentWriter(
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository, private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository, private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory, private val idGenerator: IDGenerator,
) { ) {
fun createPayment( fun createPayment(
@ -32,7 +31,7 @@ class PaymentWriter(
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" } log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
return paymentClientConfirmResponse.toEntity( return paymentClientConfirmResponse.toEntity(
id = tsidFactory.next(), reservationId, orderId, paymentType id = idGenerator.create(), reservationId, orderId, paymentType
).also { ).also {
paymentRepository.save(it) paymentRepository.save(it)
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" } log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
@ -44,7 +43,7 @@ class PaymentWriter(
paymentId: Long, paymentId: Long,
): PaymentDetailEntity { ): PaymentDetailEntity {
val method: PaymentMethod = paymentResponse.method val method: PaymentMethod = paymentResponse.method
val id = tsidFactory.next() val id = idGenerator.create()
if (method == PaymentMethod.TRANSFER) { if (method == PaymentMethod.TRANSFER) {
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId)) return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId))
@ -69,7 +68,7 @@ class PaymentWriter(
paymentRepository.save(payment.apply { this.cancel() }) paymentRepository.save(payment.apply { this.cancel() })
return cancelResponse.cancels.toEntity( return cancelResponse.cancels.toEntity(
id = tsidFactory.next(), id = idGenerator.create(),
paymentId = payment.id, paymentId = payment.id,
cancelRequestedAt = requestedAt, cancelRequestedAt = requestedAt,
canceledBy = userId canceledBy = userId

View File

@ -1,5 +1,12 @@
package roomescape.payment.docs package com.sangdol.roomescape.payment.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.web.PaymentCancelRequest
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
import 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
@ -7,13 +14,6 @@ import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.User
import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentConfirmRequest
import roomescape.payment.web.PaymentCreateResponse
interface PaymentAPI { interface PaymentAPI {

View File

@ -1,7 +1,7 @@
package roomescape.payment.exception package com.sangdol.roomescape.payment.exception
import org.springframework.http.HttpStatus import com.sangdol.common.types.web.HttpStatus
import roomescape.common.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
enum class PaymentErrorCode( enum class PaymentErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -1,6 +1,6 @@
package roomescape.payment.exception package com.sangdol.roomescape.payment.exception
import roomescape.common.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
class PaymentException( class PaymentException(
override val errorCode: PaymentErrorCode, override val errorCode: PaymentErrorCode,

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties

View File

@ -1,11 +1,11 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import roomescape.payment.infrastructure.common.PaymentStatus import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime

View File

@ -1,6 +1,8 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
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.http.HttpMethod import org.springframework.http.HttpMethod
@ -10,8 +12,6 @@ import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.client.ResponseErrorHandler import org.springframework.web.client.ResponseErrorHandler
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import java.net.URI import java.net.URI
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,12 +1,12 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.infrastructure.common.*
import roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentClientConfirmResponse( data class PaymentClientConfirmResponse(

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
data class TosspayErrorResponse( data class TosspayErrorResponse(
val code: String, val code: String,

View File

@ -1,10 +1,10 @@
package roomescape.payment.infrastructure.common package com.sangdol.roomescape.payment.infrastructure.common
import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonCreator
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 roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,8 +1,8 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Table import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository

View File

@ -1,8 +1,8 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.payment.infrastructure.common.*
import jakarta.persistence.* import jakarta.persistence.*
import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.*
@Entity @Entity
@Table(name = "payment_detail") @Table(name = "payment_detail")

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository

View File

@ -1,13 +1,13 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.Table import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Entity @Entity

View File

@ -1,4 +1,4 @@
package roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository

View File

@ -1,13 +1,13 @@
package roomescape.payment.web package com.sangdol.roomescape.payment.web
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.docs.PaymentAPI
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.business.PaymentService
import roomescape.payment.docs.PaymentAPI
@RestController @RestController
@RequestMapping("/payments") @RequestMapping("/payments")

View File

@ -1,11 +1,11 @@
package roomescape.payment.web package com.sangdol.roomescape.payment.web
import roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.PaymentStatus import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
import roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import roomescape.payment.web.PaymentDetailResponse.* import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime

View File

@ -1,13 +1,13 @@
package roomescape.region.business package com.sangdol.roomescape.region.business
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.region.exception.RegionErrorCode import com.sangdol.roomescape.region.exception.RegionErrorCode
import roomescape.region.exception.RegionException import com.sangdol.roomescape.region.exception.RegionException
import roomescape.region.infrastructure.persistence.RegionRepository import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
import roomescape.region.web.* import com.sangdol.roomescape.region.web.*
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,15 +1,15 @@
package roomescape.region.docs package com.sangdol.roomescape.region.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.region.web.RegionCodeResponse
import com.sangdol.roomescape.region.web.SidoListResponse
import com.sangdol.roomescape.region.web.SigunguListResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Public
import roomescape.common.dto.response.CommonApiResponse
import roomescape.region.web.RegionCodeResponse
import roomescape.region.web.SidoListResponse
import roomescape.region.web.SigunguListResponse
interface RegionAPI { interface RegionAPI {

View File

@ -1,8 +1,8 @@
package roomescape.region.exception package com.sangdol.roomescape.region.exception
import org.springframework.http.HttpStatus import com.sangdol.common.types.web.HttpStatus
import roomescape.common.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import roomescape.common.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
class RegionException( class RegionException(
override val errorCode: RegionErrorCode, override val errorCode: RegionErrorCode,

View File

@ -1,4 +1,4 @@
package roomescape.region.infrastructure.persistence package com.sangdol.roomescape.region.infrastructure.persistence
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id

View File

@ -1,4 +1,4 @@
package roomescape.region.infrastructure.persistence package com.sangdol.roomescape.region.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query

View File

@ -1,13 +1,13 @@
package roomescape.region.web package com.sangdol.roomescape.region.web
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.region.business.RegionService
import com.sangdol.roomescape.region.docs.RegionAPI
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import roomescape.common.dto.response.CommonApiResponse
import roomescape.region.business.RegionService
import roomescape.region.docs.RegionAPI
@RestController @RestController
@RequestMapping("/regions") @RequestMapping("/regions")

View File

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

View File

@ -1,28 +1,25 @@
package roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import com.github.f4b6a3.tsid.TsidFactory import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.infrastructure.persistence.*
import com.sangdol.roomescape.reservation.web.*
import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.user.business.UserService
import com.sangdol.roomescape.user.web.UserContactResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.common.dto.CurrentUserContext
import roomescape.common.util.DateUtils
import roomescape.payment.business.PaymentService
import roomescape.payment.web.PaymentWithDetailResponse
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.*
import roomescape.reservation.web.*
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleOverviewResponse
import roomescape.schedule.web.ScheduleUpdateRequest
import roomescape.theme.business.ThemeService
import roomescape.user.business.UserService
import roomescape.user.web.UserContactResponse
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -35,7 +32,7 @@ class ReservationService(
private val userService: UserService, private val userService: UserService,
private val themeService: ThemeService, private val themeService: ThemeService,
private val canceledReservationRepository: CanceledReservationRepository, private val canceledReservationRepository: CanceledReservationRepository,
private val tsidFactory: TsidFactory, private val idGenerator: IDGenerator,
private val paymentService: PaymentService private val paymentService: PaymentService
) { ) {
@ -48,7 +45,7 @@ class ReservationService(
validateCanCreate(request) validateCanCreate(request)
val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.id) val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id)
return PendingReservationCreateResponse(reservationRepository.save(reservation).id) return PendingReservationCreateResponse(reservationRepository.save(reservation).id)
.also { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } .also { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
@ -143,7 +140,7 @@ class ReservationService(
} }
CanceledReservationEntity( CanceledReservationEntity(
id = tsidFactory.next(), id = idGenerator.create(),
reservationId = reservation.id, reservationId = reservation.id,
canceledBy = user.id, canceledBy = user.id,
cancelReason = cancelReason, cancelReason = cancelReason,

View File

@ -1,14 +1,14 @@
package roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest
import roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleSummaryResponse import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse
import roomescape.theme.web.ThemeInfoResponse import com.sangdol.roomescape.theme.web.ThemeInfoResponse
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,5 +1,10 @@
package roomescape.reservation.docs package com.sangdol.roomescape.reservation.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.reservation.web.*
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
@ -7,13 +12,6 @@ import jakarta.validation.Valid
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
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Public
import roomescape.auth.web.support.User
import roomescape.auth.web.support.UserOnly
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
interface ReservationAPI { interface ReservationAPI {
@Operation(summary = "결제 전 임시 예약 저장") @Operation(summary = "결제 전 임시 예약 저장")

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