[#54] 애플리케이션 배포 #55

Merged
pricelees merged 20 commits from infra/#54 into main 2025-10-06 02:42:13 +00:00
124 changed files with 938 additions and 727 deletions

View File

@ -1,10 +1,29 @@
FROM gradle:8-jdk17 AS builder FROM gradle:8-jdk17 AS dependencies
WORKDIR /app WORKDIR /app
COPY gradlew settings.gradle build.gradle.kts /app/
COPY gradle /app/gradle
COPY service/build.gradle.kts /app/service/
COPY tosspay-mock/build.gradle.kts /app/tosspay-mock/
COPY common/log/build.gradle.kts /app/common/log/
COPY common/persistence/build.gradle.kts /app/common/persistence/
COPY common/types/build.gradle.kts /app/common/types/
COPY common/utils/build.gradle.kts /app/common/utils/
COPY common/web/build.gradle.kts /app/common/web/
RUN ./gradlew dependencies --no-daemon
FROM dependencies AS builder
WORKDIR /app
COPY . . COPY . .
RUN ./gradlew bootjar --no-daemon
RUN ./gradlew :service:bootjar --no-daemon
FROM amazoncorretto:17 FROM amazoncorretto:17
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
COPY --from=builder /app/service/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -7,7 +7,6 @@ import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -48,7 +47,10 @@ class AbstractLogMaskingConverterTest : FunSpec({
event.formattedMessage event.formattedMessage
} returns json.format(account, address) } returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format("${account.first()}${converter.mask}${account.last()}", "${address.first()}${converter.mask}${address.last()}") converter.convert(event) shouldBeEqual json.format(
"${account.first()}${converter.mask}${account.last()}",
"${address.first()}${converter.mask}${address.last()}"
)
} }
} }
} }

View File

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

View File

@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository
class TestAuditingBaseEntity( class TestAuditingBaseEntity(
id: Long, id: Long,
val name: String val name: String
): AuditingBaseEntity(id) ) : AuditingBaseEntity(id)
interface TestAuditingBaseEntityRepository: JpaRepository<TestAuditingBaseEntity, Long> interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>

View File

@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository
class TestPersistableBaseEntity( class TestPersistableBaseEntity(
id: Long, id: Long,
val name: String val name: String
): PersistableBaseEntity(id) ) : PersistableBaseEntity(id)
interface TestPersistableBaseEntityRepository: JpaRepository<TestPersistableBaseEntity, Long> interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>

View File

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

View File

@ -0,0 +1,21 @@
package com.sangdol.common.utils
import java.time.*
import java.time.temporal.ChronoUnit
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
object KoreaDate {
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
}
object KoreaTime {
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
}
object KoreaDateTime {
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
}
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()

View File

@ -0,0 +1,45 @@
package com.sangdol.common.utils
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.*
class KoreaDateTimeExtensionsTest : FunSpec({
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
assertSoftly(KoreaTime.now()) {
val utcNow = LocalTime.now(ZoneId.of("UTC"))
this.hour shouldBe utcNow.hour.plus(9)
this.minute shouldBe utcNow.minute
this.second shouldBe 0
this.nano shouldBe 0
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.now()) {
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.nowWithOffset()) {
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
.withSecond(0).withNano(0)
}
}
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
val now = Instant.now()
val kstConverted = now.toKoreaDateTime()
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
}
})

View File

@ -0,0 +1,26 @@
package com.sangdol.common.web.asepct
import io.micrometer.observation.Observation
import io.micrometer.observation.ObservationRegistry
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
@Aspect
class ServiceObservationAspect(
private val observationRegistry: ObservationRegistry
) {
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
fun allServices() {
}
@Around("allServices()")
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
val methodName: String = joinPoint.signature.toShortString()
return Observation.createNotStarted(methodName, observationRegistry)
.observe<Any?> { joinPoint.proceed() }
}
}

View File

@ -1,11 +1,8 @@
package com.sangdol.common.web.config package com.sangdol.common.web.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
@ -15,19 +12,13 @@ import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Configuration @Configuration
class JacksonConfig { class JacksonConfig {
companion object { companion object {
private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
private val LOCAL_TIME_FORMATTER: DateTimeFormatter = private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm") DateTimeFormatter.ofPattern("HH:mm")
} }
@ -35,9 +26,9 @@ class JacksonConfig {
@Bean @Bean
fun objectMapper(): ObjectMapper = ObjectMapper() fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule()) .registerModule(javaTimeModule())
.registerModule(dateTimeModule())
.registerModule(kotlinModule()) .registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer( .addSerializer(
@ -56,35 +47,4 @@ class JacksonConfig {
LocalTime::class.java, LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER) LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule ) as JavaTimeModule
private fun dateTimeModule(): SimpleModule {
val simpleModule = SimpleModule()
simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer())
simpleModule.addSerializer(OffsetDateTime::class.java, OffsetDateTimeSerializer())
return simpleModule
}
class LocalDateTimeSerializer : JsonSerializer<LocalDateTime>() {
override fun serialize(
value: LocalDateTime,
gen: JsonGenerator,
serializers: SerializerProvider
) {
value.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
.also {
gen.writeString(it.format(ISO_OFFSET_DATE_TIME_FORMATTER))
}
}
}
class OffsetDateTimeSerializer : JsonSerializer<OffsetDateTime>() {
override fun serialize(
value: OffsetDateTime,
gen: JsonGenerator,
serializers: SerializerProvider
) {
gen.writeString(value.format(ISO_OFFSET_DATE_TIME_FORMATTER))
}
}
} }

View File

@ -0,0 +1,41 @@
package com.sangdol.common.web.config
import com.sangdol.common.web.asepct.ServiceObservationAspect
import io.micrometer.observation.ObservationPredicate
import io.micrometer.observation.ObservationRegistry
import io.micrometer.observation.aop.ObservedAspect
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.server.observation.ServerRequestObservationContext
@Configuration
class ObservationConfig(
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
) {
@Bean
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
return ObservedAspect(observationRegistry)
}
@Bean
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
return ServiceObservationAspect(observationRegistry)
}
@Bean
fun excludeActuatorPredicate(): ObservationPredicate {
return ObservationPredicate { _, context ->
if (context !is ServerRequestObservationContext) {
return@ObservationPredicate true
}
val servletRequest: HttpServletRequest = context.carrier
val requestUri = servletRequest.requestURI
!requestUri.contains(actuatorPath)
}
}
}

View File

@ -18,7 +18,10 @@ class WebLogMessageConverter(
return objectMapper.writeValueAsString(payload) return objectMapper.writeValueAsString(payload)
} }
fun convertToControllerInvokedMessage(servletRequest: HttpServletRequest, controllerPayload: Map<String, Any>): String { fun convertToControllerInvokedMessage(
servletRequest: HttpServletRequest,
controllerPayload: Map<String, Any>
): String {
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest) val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint() .endpoint()
.principalId() .principalId()

View File

@ -7,10 +7,7 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class JacksonConfigTest : FunSpec({ class JacksonConfigTest : FunSpec({
@ -55,38 +52,4 @@ class JacksonConfigTest : FunSpec({
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed" }.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
} }
} }
context("Long 타입은 문자열로 (역)직렬화된다.") {
val number = 1234567890L
val serialized: String = objectMapper.writeValueAsString(number)
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
test("Long 직렬화") {
serialized shouldBe "$number"
}
test("Long 역직렬화") {
deserialized shouldBe number
}
}
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
val date = LocalDate.of(2025, 7, 14)
val time = LocalTime.of(12, 30, 0)
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("OffsetDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("LocalDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
}) })

View File

@ -23,7 +23,7 @@ class LogPayloadBuilderTest : FunSpec({
beforeTest { beforeTest {
method = "GET".also { every { servletRequest.method } returns it } method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it } requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it } remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it } userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it } queryString = "key=value".also { every { servletRequest.queryString } returns it }
} }

View File

@ -29,7 +29,7 @@ class WebLogMessageConverterTest : FunSpec({
beforeTest { beforeTest {
method = "GET".also { every { servletRequest.method } returns it } method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it } requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it } remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it } userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it } queryString = "key=value".also { every { servletRequest.queryString } returns it }
} }
@ -121,7 +121,10 @@ class WebLogMessageConverterTest : FunSpec({
this["duration_ms"].shouldNotBeNull() this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null this["response_body"] shouldBe null
this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message) this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
} }
} }
@ -141,7 +144,10 @@ class WebLogMessageConverterTest : FunSpec({
this["duration_ms"].shouldNotBeNull() this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body this["response_body"] shouldBe body
this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message) this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,28 +0,0 @@
import React from 'react';
import {Navigate, useLocation} from 'react-router-dom';
import {useAuth} from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>; // Or a proper spinner component
}
if (!loggedIn) {
// Not logged in, redirect to login page. No alert needed here
// as the user is simply redirected.
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role !== 'ADMIN') {
// Logged in but not an admin, show alert and redirect.
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
return <Navigate to="/" replace />;
}
return children;
};
export default AdminRoute;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ class AuthService(
request: LoginRequest, request: LoginRequest,
context: LoginContext context: LoginContext
): LoginSuccessResponse { ): LoginSuccessResponse {
log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request) val (credentials, extraClaims) = getCredentials(request)
try { try {
@ -40,7 +40,7 @@ class AuthService(
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
return credentials.toResponse(accessToken).also { return credentials.toResponse(accessToken).also {
log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -48,12 +48,12 @@ class AuthService(
when (e) { when (e) {
is AuthException -> { is AuthException -> {
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" } log.info { "[login] 로그인 실패: account = ${request.account}" }
throw e throw e
} }
else -> { else -> {
log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" } log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
} }
} }
@ -65,7 +65,7 @@ class AuthService(
credentials: LoginCredentials credentials: LoginCredentials
) { ) {
if (credentials.password != request.password) { if (credentials.password != request.password) {
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED) throw AuthException(AuthErrorCode.LOGIN_FAILED)
} }
} }

View File

@ -42,7 +42,7 @@ class LoginHistoryService(
success: Boolean, success: Boolean,
context: LoginContext context: LoginContext
) { ) {
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" } log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
runCatching { runCatching {
LoginHistoryEntity( LoginHistoryEntity(
@ -54,10 +54,10 @@ class LoginHistoryService(
userAgent = context.userAgent, userAgent = context.userAgent,
).also { ).also {
loginHistoryRepository.save(it) loginHistoryRepository.save(it)
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" } log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
} }
}.onFailure { }.onFailure {
log.warn { "[LoginHistoryService] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" } log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
} }
} }
} }

View File

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

View File

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

View File

@ -1,5 +1,7 @@
package com.sangdol.roomescape.auth.infrastructure.jwt package com.sangdol.roomescape.auth.infrastructure.jwt
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.jsonwebtoken.Claims import io.jsonwebtoken.Claims
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey

View File

@ -5,7 +5,7 @@ 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 java.time.LocalDateTime import java.time.Instant
@Entity @Entity
@Table(name = "login_history") @Table(name = "login_history")
@ -24,5 +24,5 @@ class LoginHistoryEntity(
@Column(updatable = false) @Column(updatable = false)
@CreatedDate @CreatedDate
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
) : PersistableBaseEntity(id) ) : PersistableBaseEntity(id)

View File

@ -1,6 +1,5 @@
package com.sangdol.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
enum class PrincipalType { enum class PrincipalType {

View File

@ -1,12 +1,6 @@
package com.sangdol.roomescape.auth.web.support.interceptors package com.sangdol.roomescape.auth.web.support.interceptors
import io.github.oshai.kotlinlogging.KLogger import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
@ -17,7 +11,13 @@ import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.accessToken import com.sangdol.roomescape.auth.web.support.accessToken
import com.sangdol.common.utils.MdcPrincipalIdUtil import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -47,7 +47,10 @@ class AdminInterceptor(
return true return true
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is AuthException -> { throw e } is AuthException -> {
throw e
}
else -> { else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" } log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

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

View File

@ -1,5 +1,11 @@
package com.sangdol.roomescape.auth.web.support.resolver package com.sangdol.roomescape.auth.web.support.resolver
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.accessToken
import com.sangdol.roomescape.user.business.UserService
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,12 +15,6 @@ import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer import org.springframework.web.method.support.ModelAndViewContainer
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.accessToken
import com.sangdol.roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

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

View File

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

View File

@ -1,7 +1,6 @@
package com.sangdol.roomescape.common.config package com.sangdol.roomescape.common.config
import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration

View File

@ -0,0 +1,20 @@
package com.sangdol.roomescape.common.config
import io.micrometer.observation.ObservationPredicate
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class TraceConfig {
companion object {
val scheduleTaskName = "tasks.scheduled.execution"
}
@Bean
fun excludeSchedulerPredicate(): ObservationPredicate {
return ObservationPredicate { name, context ->
!name.equals(scheduleTaskName)
}
}
}

View File

@ -1,12 +1,12 @@
package com.sangdol.roomescape.common.config package com.sangdol.roomescape.common.config
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(

View File

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

View File

@ -1,9 +1,5 @@
package com.sangdol.roomescape.payment.business package com.sangdol.roomescape.payment.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
@ -12,6 +8,10 @@ import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirm
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.web.* import com.sangdol.roomescape.payment.web.*
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -42,7 +42,7 @@ class PaymentService(
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
} ?: run { } ?: run {
log.warn { "[PaymentService.confirm] 결제 확정 중 예상치 못한 null 반환" } log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
} }
} }
@ -64,13 +64,13 @@ class PaymentService(
cancelResponse = clientCancelResponse cancelResponse = clientCancelResponse
) )
}.also { }.also {
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" } log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? { fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
log.info { "[PaymentService.findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" } log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId) val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) } val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
@ -83,49 +83,49 @@ class PaymentService(
} }
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity { private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId) return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } } ?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
?: run { ?: run {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" } log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
} }
} }
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? { private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId) return paymentRepository.findByReservationId(reservationId)
.also { .also {
if (it != null) { if (it != null) {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
} else { } else {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" } log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
} }
} }
} }
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? { private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" } log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also { return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) { if (it != null) {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" } log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
} else { } else {
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" } log.warn { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
} }
} }
} }
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? { private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" } log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also { return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) { if (it == null) {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" } log.info { "[findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
} else { } else {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" } log.info { "[findDetailByReservationId] 취소 결제 정보 조회 완료: paymentId=${paymentId}, cancelId=${it.id}" }
} }
} }
} }

View File

@ -10,7 +10,7 @@ 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 java.time.LocalDateTime import java.time.Instant
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -60,7 +60,7 @@ class PaymentWriter(
fun cancel( fun cancel(
userId: Long, userId: Long,
payment: PaymentEntity, payment: PaymentEntity,
requestedAt: LocalDateTime, requestedAt: Instant,
cancelResponse: PaymentClientCancelResponse cancelResponse: PaymentClientCancelResponse
): CanceledPaymentEntity { ): CanceledPaymentEntity {
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" } log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }

View File

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

View File

@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import java.time.LocalDateTime import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentClientCancelResponse( data class PaymentClientCancelResponse(
@ -28,10 +28,10 @@ fun CancelDetail.toEntity(
id: Long, id: Long,
paymentId: Long, paymentId: Long,
canceledBy: Long, canceledBy: Long,
cancelRequestedAt: LocalDateTime cancelRequestedAt: Instant
) = CanceledPaymentEntity( ) = CanceledPaymentEntity(
id = id, id = id,
canceledAt = this.canceledAt, canceledAt = this.canceledAt.toInstant(),
requestedAt = cancelRequestedAt, requestedAt = cancelRequestedAt,
paymentId = paymentId, paymentId = paymentId,
canceledBy = canceledBy, canceledBy = canceledBy,

View File

@ -34,8 +34,8 @@ fun PaymentClientConfirmResponse.toEntity(
paymentKey = this.paymentKey, paymentKey = this.paymentKey,
orderId = orderId, orderId = orderId,
totalAmount = this.totalAmount, totalAmount = this.totalAmount,
requestedAt = this.requestedAt, requestedAt = this.requestedAt.toInstant(),
approvedAt = this.approvedAt, approvedAt = this.approvedAt.toInstant(),
type = paymentType, type = paymentType,
method = this.method, method = this.method,
status = this.status, status = this.status,

View File

@ -1,10 +1,10 @@
package com.sangdol.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.KotlinLogging
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

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

View File

@ -8,7 +8,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.OffsetDateTime import java.time.Instant
@Entity @Entity
@Table(name = "payment") @Table(name = "payment")
@ -19,8 +19,8 @@ class PaymentEntity(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,
val totalAmount: Int, val totalAmount: Int,
val requestedAt: OffsetDateTime, val requestedAt: Instant,
val approvedAt: OffsetDateTime, val approvedAt: Instant,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val type: PaymentType, val type: PaymentType,

View File

@ -6,8 +6,7 @@ import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.web.PaymentDetailResponse.* import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
import java.time.LocalDateTime import java.time.Instant
import java.time.OffsetDateTime
data class PaymentConfirmRequest( data class PaymentConfirmRequest(
val paymentKey: String, val paymentKey: String,
@ -24,7 +23,7 @@ data class PaymentCreateResponse(
data class PaymentCancelRequest( data class PaymentCancelRequest(
val reservationId: Long, val reservationId: Long,
val cancelReason: String, val cancelReason: String,
val requestedAt: LocalDateTime = LocalDateTime.now() val requestedAt: Instant = Instant.now()
) )
data class PaymentWithDetailResponse( data class PaymentWithDetailResponse(
@ -32,8 +31,8 @@ data class PaymentWithDetailResponse(
val totalAmount: Int, val totalAmount: Int,
val method: String, val method: String,
val status: PaymentStatus, val status: PaymentStatus,
val requestedAt: OffsetDateTime, val requestedAt: Instant,
val approvedAt: OffsetDateTime, val approvedAt: Instant,
val detail: PaymentDetailResponse?, val detail: PaymentDetailResponse?,
val cancel: PaymentCancelDetailResponse?, val cancel: PaymentCancelDetailResponse?,
) )
@ -120,8 +119,8 @@ fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayP
} }
data class PaymentCancelDetailResponse( data class PaymentCancelDetailResponse(
val cancellationRequestedAt: LocalDateTime, val cancellationRequestedAt: Instant,
val cancellationApprovedAt: OffsetDateTime?, val cancellationApprovedAt: Instant?,
val cancelReason: String, val cancelReason: String,
val canceledBy: Long, val canceledBy: Long,
) )

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.Instant
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -40,19 +40,19 @@ class ReservationService(
user: CurrentUserContext, user: CurrentUserContext,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
): PendingReservationCreateResponse { ): PendingReservationCreateResponse {
log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
validateCanCreate(request) validateCanCreate(request)
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), 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 { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
} }
@Transactional @Transactional
fun confirmReservation(id: Long) { fun confirmReservation(id: Long) {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 시작: reservationId=${id}" } log.info { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
run { run {
@ -63,13 +63,13 @@ class ReservationService(
changeStatus = ScheduleStatus.RESERVED changeStatus = ScheduleStatus.RESERVED
) )
}.also { }.also {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" } log.info { "[confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
} }
} }
@Transactional @Transactional
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) { fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
log.info { "[ReservationService.cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" } log.info { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId) val reservation: ReservationEntity = findOrThrow(reservationId)
@ -82,13 +82,13 @@ class ReservationService(
saveCanceledReservation(user, reservation, request.cancelReason) saveCanceledReservation(user, reservation, request.cancelReason)
reservation.cancel() reservation.cancel()
}.also { }.also {
log.info { "[ReservationService.cancelReservation] 예약 취소 완료: reservationId=${reservationId}" } log.info { "[cancelReservation] 예약 취소 완료: reservationId=${reservationId}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse { fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" } log.info { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn( val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
userId = user.id, userId = user.id,
@ -99,13 +99,13 @@ class ReservationService(
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId) val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId)
it.toOverviewResponse(schedule) it.toOverviewResponse(schedule)
}).also { }).also {
log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationDetailResponse { fun findDetailById(id: Long): ReservationDetailResponse {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 시작: reservationId=${id}" } log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId) val user: UserContactResponse = userService.findContactById(reservation.userId)
@ -115,17 +115,17 @@ class ReservationService(
user = user, user = user,
payment = paymentDetail payment = paymentDetail
).also { ).also {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 완료: reservationId=${id}" } log.info { "[findDetailById] 예약 상세 조회 완료: reservationId=${id}" }
} }
} }
private fun findOrThrow(id: Long): ReservationEntity { private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" } log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id) return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[ReservationService.findOrThrow] 예약 조회 완료: reservationId=${id}" } } ?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
?: run { ?: run {
log.warn { "[ReservationService.findOrThrow] 예약 조회 실패: reservationId=${id}" } log.warn { "[findOrThrow] 예약 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
} }
} }
@ -136,7 +136,7 @@ class ReservationService(
cancelReason: String cancelReason: String
) { ) {
if (reservation.userId != user.id) { if (reservation.userId != user.id) {
log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" } log.warn { "[createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION) throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
} }
@ -145,7 +145,7 @@ class ReservationService(
reservationId = reservation.id, reservationId = reservation.id,
canceledBy = user.id, canceledBy = user.id,
cancelReason = cancelReason, cancelReason = cancelReason,
canceledAt = LocalDateTime.now(), canceledAt = Instant.now(),
status = CanceledReservationStatus.COMPLETED status = CanceledReservationStatus.COMPLETED
).also { ).also {
canceledReservationRepository.save(it) canceledReservationRepository.save(it)

View File

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

View File

@ -9,7 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -26,7 +26,7 @@ class IncompletedReservationScheduler(
fun processExpiredHoldSchedule() { fun processExpiredHoldSchedule() {
log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
scheduleRepository.releaseExpiredHolds(LocalDateTime.now()).also { scheduleRepository.releaseExpiredHolds(Instant.now()).also {
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" }
} }
} }
@ -36,7 +36,7 @@ class IncompletedReservationScheduler(
fun processExpiredReservation() { fun processExpiredReservation() {
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " }
reservationRepository.expirePendingReservations(LocalDateTime.now()).also { reservationRepository.expirePendingReservations(Instant.now()).also {
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
} }
} }

View File

@ -1,9 +1,9 @@
package com.sangdol.roomescape.reservation.docs package com.sangdol.roomescape.reservation.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.web.* 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

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.reservation.exception package com.sangdol.roomescape.reservation.exception
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class ReservationErrorCode( enum class ReservationErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -5,7 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.Instant
@Entity @Entity
@Table(name = "canceled_reservation") @Table(name = "canceled_reservation")
@ -15,7 +15,7 @@ class CanceledReservationEntity(
val reservationId: Long, val reservationId: Long,
val canceledBy: Long, val canceledBy: Long,
val cancelReason: String, val cancelReason: String,
val canceledAt: LocalDateTime, val canceledAt: Instant,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
val status: CanceledReservationStatus, val status: CanceledReservationStatus,

View File

@ -4,14 +4,15 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import java.time.LocalDateTime import java.time.Instant
interface ReservationRepository : JpaRepository<ReservationEntity, Long> { interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity> fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
@Modifying @Modifying
@Query(""" @Query(
"""
UPDATE UPDATE
reservation r reservation r
JOIN JOIN
@ -23,6 +24,7 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
s.hold_expired_at = NULL s.hold_expired_at = NULL
WHERE WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
""", nativeQuery = true) """, nativeQuery = true
fun expirePendingReservations(@Param("now") now: LocalDateTime): Int )
fun expirePendingReservations(@Param("now") now: Instant): Int
} }

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.reservation.web package com.sangdol.roomescape.reservation.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.ReservationService
import com.sangdol.roomescape.reservation.docs.ReservationAPI import com.sangdol.roomescape.reservation.docs.ReservationAPI
import jakarta.validation.Valid import jakarta.validation.Valid

View File

@ -1,13 +1,13 @@
package com.sangdol.roomescape.reservation.web package com.sangdol.roomescape.reservation.web
import jakarta.validation.constraints.NotEmpty
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
import com.sangdol.roomescape.user.web.UserContactResponse import com.sangdol.roomescape.user.web.UserContactResponse
import jakarta.validation.constraints.NotEmpty
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
data class PendingReservationCreateRequest( data class PendingReservationCreateRequest(
@ -79,7 +79,7 @@ data class ReservationDetailResponse(
val id: Long, val id: Long,
val reserver: ReserverInfo, val reserver: ReserverInfo,
val user: UserContactResponse, val user: UserContactResponse,
val applicationDateTime: LocalDateTime, val applicationDateTime: Instant,
val payment: PaymentWithDetailResponse?, val payment: PaymentWithDetailResponse?,
) )

View File

@ -1,6 +1,8 @@
package com.sangdol.roomescape.schedule.business package com.sangdol.roomescape.schedule.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.common.types.Auditor import com.sangdol.roomescape.common.types.Auditor
@ -43,21 +45,23 @@ class ScheduleService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
val currentDate = LocalDate.now()
val currentDate: LocalDate = KoreaDate.today()
val currentTime: LocalTime = KoreaTime.now()
if (date.isBefore(currentDate)) { if (date.isBefore(currentDate)) {
log.warn { "[ScheduleService.getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" } log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
} }
val schedules: List<ScheduleOverview> = val schedules: List<ScheduleOverview> =
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
.filter { it.date.isAfter(date) || (it.date.isEqual(date) && it.time.isAfter(LocalTime.now())) } .filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
return schedules.toResponse() return schedules.toResponse()
.also { .also {
log.info { "[ScheduleService.getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" } log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
} }
} }
@ -66,20 +70,20 @@ class ScheduleService(
// ======================================== // ========================================
@Transactional @Transactional
fun holdSchedule(id: Long) { fun holdSchedule(id: Long) {
log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" } log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
val result: Int = scheduleRepository.changeStatus( val result: Int = scheduleRepository.changeStatus(
id = id, id = id,
currentStatus = ScheduleStatus.AVAILABLE, currentStatus = ScheduleStatus.AVAILABLE,
changeStatus = ScheduleStatus.HOLD changeStatus = ScheduleStatus.HOLD
).also { ).also {
log.info { "[ScheduleService.holdSchedule] $it 개의 row 변경 완료" } log.info { "[holdSchedule] $it 개의 row 변경 완료" }
} }
if (result == 0) { if (result == 0) {
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
} }
log.info { "[ScheduleService.holdSchedule] 일정 Holding 완료: id=$id" } log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
} }
// ======================================== // ========================================
@ -87,9 +91,9 @@ class ScheduleService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
log.info { "[ScheduleService.searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
val searchDate = date ?: LocalDate.now() val searchDate = date ?: KoreaDate.today()
val schedules: List<ScheduleOverview> = val schedules: List<ScheduleOverview> =
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate)
@ -98,13 +102,13 @@ class ScheduleService(
return schedules.toAdminSummaryListResponse() return schedules.toAdminSummaryListResponse()
.also { .also {
log.info { "[ScheduleService.searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditingInfo { fun findScheduleAudit(id: Long): AuditingInfo {
log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 시작: id=$id" } log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id)
@ -112,7 +116,7 @@ class ScheduleService(
val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy) val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy)
return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy) return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy)
.also { log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 완료: id=$id" } } .also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } }
} }
// ======================================== // ========================================
@ -120,7 +124,7 @@ class ScheduleService(
// ======================================== // ========================================
@Transactional @Transactional
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(storeId, request) scheduleValidator.validateCanCreate(storeId, request)
@ -136,16 +140,16 @@ class ScheduleService(
return ScheduleCreateResponse(schedule.id) return ScheduleCreateResponse(schedule.id)
.also { .also {
log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" } log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
} }
} }
@Transactional @Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) { if (request.isAllParamsNull()) {
log.info { "[ScheduleService.updateSchedule] 일정 변경 사항 없음: id=$id" } log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
return return
} }
@ -154,20 +158,20 @@ class ScheduleService(
} }
schedule.modifyIfNotNull(request.time, request.status).also { schedule.modifyIfNotNull(request.time, request.status).also {
log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" } log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
} }
} }
@Transactional @Transactional
fun deleteSchedule(id: Long) { fun deleteSchedule(id: Long) {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" } log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id).also { val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it) scheduleValidator.validateCanDelete(it)
} }
scheduleRepository.delete(schedule).also { scheduleRepository.delete(schedule).also {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" } log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" }
} }
} }
@ -176,24 +180,24 @@ class ScheduleService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findSummaryWithLock(id: Long): ScheduleSummaryResponse { fun findSummaryWithLock(id: Long): ScheduleSummaryResponse {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" } log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
?: run { ?: run {
log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" } log.warn { "[updateSchedule] 일정 조회 실패. id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
return schedule.toSummaryResponse() return schedule.toSummaryResponse()
.also { .also {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" } log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse { fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse {
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run { val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
log.warn { "[ScheduleService.findScheduleOverview] 일정 개요 조회 실패: id=$id" } log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
@ -202,10 +206,10 @@ class ScheduleService(
@Transactional @Transactional
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) { fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } log.info { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also { scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
} }
} }
@ -213,12 +217,12 @@ class ScheduleService(
// Common (공통 메서드) // Common (공통 메서드)
// ======================================== // ========================================
private fun findOrThrow(id: Long): ScheduleEntity { private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" } log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id) return scheduleRepository.findByIdOrNull(id)
?.also { log.info { "[ScheduleService.findOrThrow] 일정 조회 완료: id=$id" } } ?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
?: run { ?: run {
log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" } log.warn { "[updateSchedule] 일정 조회 실패. id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
} }

View File

@ -1,5 +1,6 @@
package com.sangdol.roomescape.schedule.business package com.sangdol.roomescape.schedule.business
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.exception.ScheduleException
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
@ -13,6 +14,7 @@ import org.springframework.stereotype.Component
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.temporal.ChronoUnit
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -56,9 +58,10 @@ class ScheduleValidator(
} }
private fun validateNotInPast(date: LocalDate, time: LocalTime) { private fun validateNotInPast(date: LocalDate, time: LocalTime) {
val dateTime = LocalDateTime.of(date, time) val now = KoreaDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
if (dateTime.isBefore(LocalDateTime.now())) { if (inputDateTime.isBefore(now)) {
log.info { log.info {
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" "[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
} }

View File

@ -1,12 +1,12 @@
package com.sangdol.roomescape.schedule.docs package com.sangdol.roomescape.schedule.docs
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.schedule.web.* import com.sangdol.roomescape.schedule.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

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.schedule.exception package com.sangdol.roomescape.schedule.exception
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class ScheduleErrorCode( enum class ScheduleErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -3,8 +3,8 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence
import com.sangdol.common.persistence.AuditingBaseEntity 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 java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@Entity @Entity
@ -20,7 +20,7 @@ class ScheduleEntity(
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: ScheduleStatus, var status: ScheduleStatus,
var holdExpiredAt: LocalDateTime? = null var holdExpiredAt: Instant? = null
) : AuditingBaseEntity(id) { ) : AuditingBaseEntity(id) {
fun modifyIfNotNull( fun modifyIfNotNull(
time: LocalTime?, time: LocalTime?,

View File

@ -7,21 +7,23 @@ import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> { interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE) @Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query(""" @Query(
"""
SELECT SELECT
s s
FROM FROM
ScheduleEntity s ScheduleEntity s
WHERE WHERE
s._id = :id s._id = :id
""") """
)
fun findByIdForUpdate(id: Long): ScheduleEntity? fun findByIdForUpdate(id: Long): ScheduleEntity?
@Query( @Query(
@ -108,7 +110,7 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.status = :changeStatus, s.status = :changeStatus,
s.holdExpiredAt = CASE s.holdExpiredAt = CASE
WHEN :changeStatus = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD WHEN :changeStatus = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
THEN CURRENT_TIMESTAMP + 5 MINUTE THEN :expiredAt
ELSE NULL ELSE NULL
END END
WHERE WHERE
@ -117,7 +119,12 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.status = :currentStatus s.status = :currentStatus
""" """
) )
fun changeStatus(id: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus): Int fun changeStatus(
id: Long,
currentStatus: ScheduleStatus,
changeStatus: ScheduleStatus,
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
): Int
@Modifying @Modifying
@Query( @Query(
@ -137,5 +144,5 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
) )
""" """
) )
fun releaseExpiredHolds(@Param("now") now: LocalDateTime): Int fun releaseExpiredHolds(@Param("now") now: Instant): Int
} }

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.schedule.web package com.sangdol.roomescape.schedule.web
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
import jakarta.validation.Valid import jakarta.validation.Valid

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.store.business package com.sangdol.roomescape.store.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.region.business.RegionService import com.sangdol.roomescape.region.business.RegionService
import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreErrorCode
import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.exception.StoreException
@ -27,19 +27,19 @@ class StoreService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getDetail(id: Long): DetailStoreResponse { fun getDetail(id: Long): DetailStoreResponse {
log.info { "[StoreService.getDetail] 매장 상세 조회 시작: id=${id}" } log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id) val store: StoreEntity = findOrThrow(id)
val region = regionService.findRegionInfo(store.regionCode) val region = regionService.findRegionInfo(store.regionCode)
val audit = getAuditInfo(store) val audit = getAuditInfo(store)
return store.toDetailResponse(region, audit) return store.toDetailResponse(region, audit)
.also { log.info { "[StoreService.getDetail] 매장 상세 조회 완료: id=${id}" } } .also { log.info { "[getDetail] 매장 상세 조회 완료: id=${id}" } }
} }
@Transactional @Transactional
fun register(request: StoreRegisterRequest): StoreRegisterResponse { fun register(request: StoreRegisterRequest): StoreRegisterResponse {
log.info { "[StoreService.register] 매장 등록 시작: name=${request.name}" } log.info { "[register] 매장 등록 시작: name=${request.name}" }
storeValidator.validateCanRegister(request) storeValidator.validateCanRegister(request)
@ -56,37 +56,37 @@ class StoreService(
} }
return StoreRegisterResponse(store.id).also { return StoreRegisterResponse(store.id).also {
log.info { "[StoreService.register] 매장 등록 완료: id=${store.id}, name=${request.name}" } log.info { "[register] 매장 등록 완료: id=${store.id}, name=${request.name}" }
} }
} }
@Transactional @Transactional
fun update(id: Long, request: StoreUpdateRequest) { fun update(id: Long, request: StoreUpdateRequest) {
log.info { "[StoreService.update] 매장 수정 시작: id=${id}, request=${request}" } log.info { "[update] 매장 수정 시작: id=${id}, request=${request}" }
storeValidator.validateCanUpdate(request) storeValidator.validateCanUpdate(request)
findOrThrow(id).apply { findOrThrow(id).apply {
this.modifyIfNotNull(request.name, request.address, request.contact) this.modifyIfNotNull(request.name, request.address, request.contact)
}.also { }.also {
log.info { "[StoreService.update] 매장 수정 완료: id=${id}" } log.info { "[update] 매장 수정 완료: id=${id}" }
} }
} }
@Transactional @Transactional
fun disableById(id: Long) { fun disableById(id: Long) {
log.info { "[StoreService.inactive] 매장 비활성화 시작: id=${id}" } log.info { "[inactive] 매장 비활성화 시작: id=${id}" }
findOrThrow(id).apply { findOrThrow(id).apply {
this.disable() this.disable()
}.also { }.also {
log.info { "[StoreService.inactive] 매장 비활성화 완료: id=${id}" } log.info { "[inactive] 매장 비활성화 완료: id=${id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse { fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse {
log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 시작" } log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
val regionCode: String? = when { val regionCode: String? = when {
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED) sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
@ -95,21 +95,21 @@ class StoreService(
} }
return storeRepository.findAllActiveStoresByRegion(regionCode).toSimpleListResponse() return storeRepository.findAllActiveStoresByRegion(regionCode).toSimpleListResponse()
.also { log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 완료: total=${it.stores.size}" } } .also { log.info { "[getAllActiveStores] 전체 매장 조회 완료: total=${it.stores.size}" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findStoreInfo(id: Long): StoreInfoResponse { fun findStoreInfo(id: Long): StoreInfoResponse {
log.info { "[StoreService.findStoreInfo] 매장 정보 조회 시작: id=${id}" } log.info { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id) val store: StoreEntity = findOrThrow(id)
return store.toInfoResponse() return store.toInfoResponse()
.also { log.info { "[StoreService.findStoreInfo] 매장 정보 조회 완료: id=${id}" } } .also { log.info { "[findStoreInfo] 매장 정보 조회 완료: id=${id}" } }
} }
private fun getAuditInfo(store: StoreEntity): AuditingInfo { private fun getAuditInfo(store: StoreEntity): AuditingInfo {
log.info { "[StoreService.getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" } log.info { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
val createdBy = adminService.findOperatorOrUnknown(store.createdBy) val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy) val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
@ -119,19 +119,19 @@ class StoreService(
updatedAt = store.updatedAt, updatedAt = store.updatedAt,
updatedBy = updatedBy updatedBy = updatedBy
).also { ).also {
log.info { "[StoreService.getAuditInfo] 감사 정보 조회 완료: storeId=${store.id}" } log.info { "[getAuditInfo] 감사 정보 조회 완료: storeId=${store.id}" }
} }
} }
private fun findOrThrow(id: Long): StoreEntity { private fun findOrThrow(id: Long): StoreEntity {
log.info { "[StoreService.findOrThrow] 매장 조회 시작: id=${id}" } log.info { "[findOrThrow] 매장 조회 시작: id=${id}" }
return storeRepository.findActiveStoreById(id) return storeRepository.findActiveStoreById(id)
?.also { ?.also {
log.info { "[StoreService.findOrThrow] 매장 조회 완료: id=${id}" } log.info { "[findOrThrow] 매장 조회 완료: id=${id}" }
} }
?: run { ?: run {
log.warn { "[StoreService.findOrThrow] 매장 조회 실패: id=${id}" } log.warn { "[findOrThrow] 매장 조회 실패: id=${id}" }
throw StoreException(StoreErrorCode.STORE_NOT_FOUND) throw StoreException(StoreErrorCode.STORE_NOT_FOUND)
} }
} }

View File

@ -1,13 +1,13 @@
package com.sangdol.roomescape.store.business package com.sangdol.roomescape.store.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreErrorCode
import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.exception.StoreException
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.store.web.StoreRegisterRequest import com.sangdol.roomescape.store.web.StoreRegisterRequest
import com.sangdol.roomescape.store.web.StoreUpdateRequest import com.sangdol.roomescape.store.web.StoreUpdateRequest
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.store.exception package com.sangdol.roomescape.store.exception
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class StoreException( class StoreException(
override val errorCode: StoreErrorCode, override val errorCode: StoreErrorCode,

View File

@ -1,8 +1,9 @@
package com.sangdol.roomescape.theme.business package com.sangdol.roomescape.theme.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.common.utils.KoreaDate
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
@ -13,7 +14,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -36,23 +36,23 @@ class ThemeService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findInfoById(id: Long): ThemeInfoResponse { fun findInfoById(id: Long): ThemeInfoResponse {
log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" } log.info { "[findInfoById] 테마 조회 시작: id=$id" }
return findOrThrow(id).toInfoResponse() return findOrThrow(id).toInfoResponse()
.also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } } .also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse { fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
log.info { "[ThemeService.findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" } log.info { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()) val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
val previousWeekSaturday = previousWeekSunday.plusDays(6) val previousWeekSaturday = previousWeekSunday.plusDays(6)
return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count) return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count)
.toListResponse() .toListResponse()
.also { .also {
log.info { "[ThemeService.findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" } log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
} }
} }
@ -62,16 +62,16 @@ class ThemeService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAdminThemes(): AdminThemeSummaryListResponse { fun findAdminThemes(): AdminThemeSummaryListResponse {
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll() return themeRepository.findAll()
.toAdminThemeSummaryListResponse() .toAdminThemeSummaryListResponse()
.also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } } .also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAdminThemeDetail(id: Long): AdminThemeDetailResponse { fun findAdminThemeDetail(id: Long): AdminThemeDetailResponse {
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" } log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id) val theme: ThemeEntity = findOrThrow(id)
@ -80,12 +80,12 @@ class ThemeService(
val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy) val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy)
return theme.toAdminThemeDetailResponse(audit) return theme.toAdminThemeDetailResponse(audit)
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } .also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
} }
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request) themeValidator.validateCanCreate(request)
@ -93,27 +93,27 @@ class ThemeService(
.also { themeRepository.save(it) } .also { themeRepository.save(it) }
return ThemeCreateResponse(theme.id).also { return ThemeCreateResponse(theme.id).also {
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
} }
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" } log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id) val theme: ThemeEntity = findOrThrow(id)
themeRepository.delete(theme).also { themeRepository.delete(theme).also {
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" } log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
} }
} }
@Transactional @Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) { fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" } log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) { if (request.isAllParamsNull()) {
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" } log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
return return
} }
@ -134,7 +134,7 @@ class ThemeService(
request.expectedMinutesTo, request.expectedMinutesTo,
request.isActive, request.isActive,
).also { ).also {
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" } log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" }
} }
} }
@ -143,12 +143,12 @@ class ThemeService(
// ======================================== // ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findActiveThemes(): SimpleActiveThemeListResponse { fun findActiveThemes(): SimpleActiveThemeListResponse {
log.info { "[ThemeService.findActiveThemes] open 상태인 모든 테마 조회 시작" } log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes() return themeRepository.findActiveThemes()
.toSimpleActiveThemeResponse() .toSimpleActiveThemeResponse()
.also { .also {
log.info { "[ThemeService.findActiveThemes] ${it.themes.size}개 테마 조회 완료" } log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" }
} }
} }
@ -156,12 +156,12 @@ class ThemeService(
// Common (공통 메서드) // Common (공통 메서드)
// ======================================== // ========================================
private fun findOrThrow(id: Long): ThemeEntity { private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" } log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id) return themeRepository.findByIdOrNull(id)
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } } ?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
?: run { ?: run {
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" } log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
} }
} }

View File

@ -1,13 +1,13 @@
package com.sangdol.roomescape.theme.business package com.sangdol.roomescape.theme.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.web.ThemeCreateRequest import com.sangdol.roomescape.theme.web.ThemeCreateRequest
import com.sangdol.roomescape.theme.web.ThemeUpdateRequest import com.sangdol.roomescape.theme.web.ThemeUpdateRequest
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.theme.exception package com.sangdol.roomescape.theme.exception
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class ThemeErrorCode( enum class ThemeErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.theme.infrastructure.persistence package com.sangdol.roomescape.theme.infrastructure.persistence
import com.sangdol.roomescape.theme.business.domain.ThemeInfo
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
import com.sangdol.roomescape.theme.business.domain.ThemeInfo
import java.time.LocalDate import java.time.LocalDate
interface ThemeRepository : JpaRepository<ThemeEntity, Long> { interface ThemeRepository : JpaRepository<ThemeEntity, Long> {

View File

@ -30,59 +30,59 @@ class UserService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findContextById(id: Long): CurrentUserContext { fun findContextById(id: Long): CurrentUserContext {
log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 시작: id=${id}" } log.info { "[findContextById] 현재 로그인된 회원 조회 시작: id=${id}" }
val user: UserEntity = findOrThrow(id) val user: UserEntity = findOrThrow(id)
return CurrentUserContext(user.id, user.name) return CurrentUserContext(user.id, user.name)
.also { .also {
log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 완료: id=${id}" } log.info { "[findContextById] 현재 로그인된 회원 조회 완료: id=${id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findCredentialsByAccount(email: String): UserLoginCredentials { fun findCredentialsByAccount(email: String): UserLoginCredentials {
log.info { "[UserService.findCredentialsByAccount] 회원 조회 시작: email=${email}" } log.info { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
return userRepository.findByEmail(email) return userRepository.findByEmail(email)
?.let { ?.let {
log.info { "[UserService.findCredentialsByAccount] 회원 조회 완료: id=${it.id}" } log.info { "[findCredentialsByAccount] 회원 조회 완료: id=${it.id}" }
it.toCredentials() it.toCredentials()
} }
?: run { ?: run {
log.info { "[UserService.findCredentialsByAccount] 회원 조회 실패" } log.info { "[findCredentialsByAccount] 회원 조회 실패" }
throw UserException(UserErrorCode.USER_NOT_FOUND) throw UserException(UserErrorCode.USER_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findContactById(id: Long) : UserContactResponse { fun findContactById(id: Long): UserContactResponse {
log.info { "[UserService.findContactById] 회원 연락 정보 조회 시작: id=${id}" } log.info { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
val user = findOrThrow(id) val user = findOrThrow(id)
return UserContactResponse(user.id, user.name, user.phone) return UserContactResponse(user.id, user.name, user.phone)
.also { .also {
log.info { "[UserService.findContactById] 회원 연락 정보 조회 완료: id=${id}, name=${it.name}" } log.info { "[findContactById] 회원 연락 정보 조회 완료: id=${id}, name=${it.name}" }
} }
} }
@Transactional @Transactional
fun signup(request: UserCreateRequest): UserCreateResponse { fun signup(request: UserCreateRequest): UserCreateResponse {
log.info { "[UserService.signup] 회원가입 시작: request:$request" } log.info { "[signup] 회원가입 시작: request:$request" }
userValidator.validateCanSignup(request.email, request.phone) userValidator.validateCanSignup(request.email, request.phone)
val user: UserEntity = userRepository.save( val user: UserEntity = userRepository.save(
request.toEntity(id = idGenerator.create(), status = UserStatus.ACTIVE) request.toEntity(id = idGenerator.create(), status = UserStatus.ACTIVE)
).also { ).also {
log.info { "[UserService.signup] 회원 저장 완료: id:${it.id}" } log.info { "[signup] 회원 저장 완료: id:${it.id}" }
}.also { }.also {
createHistory(user = it, reason = SIGNUP) createHistory(user = it, reason = SIGNUP)
} }
return UserCreateResponse(user.id, user.name) return UserCreateResponse(user.id, user.name)
.also { .also {
log.info { "[UserService.signup] 회원가입 완료: id:${it.id}" } log.info { "[signup] 회원가입 완료: id:${it.id}" }
} }
} }
@ -95,7 +95,7 @@ class UserService(
return userStatusHistoryRepository.save( return userStatusHistoryRepository.save(
UserStatusHistoryEntity(id = idGenerator.create(), userId = user.id, reason = reason, status = user.status) UserStatusHistoryEntity(id = idGenerator.create(), userId = user.id, reason = reason, status = user.status)
).also { ).also {
log.info { "[UserService.signup] 회원 상태 이력 저장 완료: userStatusHistoryId:${it.id}" } log.info { "[signup] 회원 상태 이력 저장 완료: userStatusHistoryId:${it.id}" }
} }
} }
} }

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.user.business package com.sangdol.roomescape.user.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import com.sangdol.roomescape.user.exception.UserErrorCode import com.sangdol.roomescape.user.exception.UserErrorCode
import com.sangdol.roomescape.user.exception.UserException import com.sangdol.roomescape.user.exception.UserException
import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.user.exception package com.sangdol.roomescape.user.exception
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class UserException( class UserException(
override val errorCode: UserErrorCode, override val errorCode: UserErrorCode,

View File

@ -22,7 +22,7 @@ class UserEntity(
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: UserStatus var status: UserStatus
): AuditingBaseEntity(id) ) : AuditingBaseEntity(id)
@Entity @Entity
@Table(name = "user_status_history") @Table(name = "user_status_history")

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.user.web package com.sangdol.roomescape.user.web
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus
import jakarta.validation.constraints.Email import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size import jakarta.validation.constraints.Size
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus
const val MIN_PASSWORD_LENGTH = 8 const val MIN_PASSWORD_LENGTH = 8

View File

@ -7,7 +7,7 @@ spring:
ddl-auto: validate ddl-auto: validate
datasource: datasource:
hikari: hikari:
jdbc-url: jdbc:mysql://localhost:23306/roomescape_local jdbc-url: jdbc:mysql://localhost:23306/roomescape_local?useLegacyDatetimeCode=false&serverTimezone=UTC
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
username: root username: root
password: init password: init
@ -42,4 +42,14 @@ jdbc:
management: management:
tracing: tracing:
sampling: sampling:
probability: 1 probability: 1.0
otlp:
tracing:
transport: http
endpoint: http://localhost:4318/v1/traces
slow-query:
logger-name: local-slow-query-logger
log-level: info
threshold-ms: 5

View File

@ -5,6 +5,8 @@ server:
forward-headers-strategy: framework forward-headers-strategy: framework
spring: spring:
application:
name: roomescape-backend
profiles: profiles:
active: ${ACTIVE_PROFILE:local} active: ${ACTIVE_PROFILE:local}
jpa: jpa:
@ -21,7 +23,7 @@ management:
show-details: always show-details: always
payment: payment:
api-base-url: https://api.tosspayments.com api-base-url: ${PAYMENT_SERVER_ENDPOINT:/https://api.tosspayments.com}
springdoc: springdoc:
swagger-ui: swagger-ui:

View File

@ -6,20 +6,6 @@
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender"> <appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers> <providers>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<mdc/>
<pattern> <pattern>
<pattern> <pattern>
{ {
@ -27,6 +13,10 @@
} }
</pattern> </pattern>
</pattern> </pattern>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<mdc/>
<stackTrace> <stackTrace>
<fieldName>stack_trace</fieldName> <fieldName>stack_trace</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter"> <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
@ -35,6 +25,16 @@
<rootCauseFirst>true</rootCauseFirst> <rootCauseFirst>true</rootCauseFirst>
</throwableConverter> </throwableConverter>
</stackTrace> </stackTrace>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
</providers> </providers>
</encoder> </encoder>
</appender> </appender>

View File

@ -2,35 +2,36 @@ package com.sangdol.data
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.infrastructure.common.*
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
import com.sangdol.roomescape.supports.AdminFixture import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.randomPhoneNumber import com.sangdol.roomescape.supports.randomPhoneNumber
import com.sangdol.roomescape.supports.randomString import com.sangdol.roomescape.supports.randomString
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.user.business.SIGNUP import com.sangdol.roomescape.user.business.SIGNUP
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus
import com.sangdol.roomescape.user.web.UserContactResponse import com.sangdol.roomescape.user.web.UserContactResponse
import io.kotest.core.test.TestCaseOrder import io.kotest.core.test.TestCaseOrder
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
import java.sql.Timestamp import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime import java.time.ZoneId
@ActiveProfiles("test", "data") @ActiveProfiles("test", "data")
abstract class AbstractDataInitializer( abstract class AbstractDataInitializer(
@ -151,22 +152,22 @@ class DefaultDataInitializer : AbstractDataInitializer() {
AdminPermissionLevel.READ_SUMMARY to 3 AdminPermissionLevel.READ_SUMMARY to 3
) )
val storeIds: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { val stores: List<StoreEntity> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery( entityManager.createQuery(
"SELECT s.id FROM StoreEntity s", "SELECT s FROM StoreEntity s",
Long::class.java StoreEntity::class.java
).resultList ).resultList
}!!.map { it as Long } }!!
transactionExecutionUtil.withNewTransaction(isReadOnly = false) { transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
storeIds.forEach { storeId -> stores.forEach { store ->
// StoreManager 1명 생성 // StoreManager 1명 생성
val storeManager = AdminFixture.create( val storeManager = AdminFixture.create(
account = "$storeId", account = store.name,
name = randomKoreanName(), name = randomKoreanName(),
phone = randomPhoneNumber(), phone = randomPhoneNumber(),
type = AdminType.STORE, type = AdminType.STORE,
storeId = storeId, storeId = store.id,
permissionLevel = AdminPermissionLevel.FULL_ACCESS permissionLevel = AdminPermissionLevel.FULL_ACCESS
).apply { ).apply {
this.createdBy = superHQAdmin.id this.createdBy = superHQAdmin.id
@ -178,11 +179,11 @@ class DefaultDataInitializer : AbstractDataInitializer() {
storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) -> storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) ->
repeat(count) { index -> repeat(count) { index ->
AdminFixture.create( AdminFixture.create(
account = randomString(), account = "${store.name}-${permissionLevel.ordinal}${index}",
name = randomKoreanName(), name = randomKoreanName(),
phone = randomPhoneNumber(), phone = randomPhoneNumber(),
type = AdminType.STORE, type = AdminType.STORE,
storeId = storeId, storeId = store.id,
permissionLevel = permissionLevel permissionLevel = permissionLevel
).apply { ).apply {
this.createdBy = storeManager.id this.createdBy = storeManager.id
@ -217,7 +218,7 @@ class DefaultDataInitializer : AbstractDataInitializer() {
val batchArgs = mutableListOf<Array<Any>>() val batchArgs = mutableListOf<Array<Any>>()
repeat(500) { i -> repeat(500) { i ->
val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random() val randomDay = if (i <= 9) (7..30).random() else (30..365 * 2).random()
val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong()) val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong())
val randomThemeName = val randomThemeName =
(1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } } (1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } }
@ -323,7 +324,7 @@ class UserDataInitializer : AbstractDataInitializer() {
} while (true) } while (true)
user.phone = newPhone user.phone = newPhone
user.updatedAt = LocalDateTime.now() user.updatedAt = Instant.now()
entityManager.merge(user) entityManager.merge(user)
} }
} }
@ -417,25 +418,49 @@ class UserDataInitializer : AbstractDataInitializer() {
class ScheduleDataInitializer : AbstractDataInitializer() { class ScheduleDataInitializer : AbstractDataInitializer() {
init { init {
context("일정 초기 데이터 생성") { context("일정 초기 데이터 생성") {
test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") { test("테마 생성일 기준으로 다음 3일차, 매일 최대 10개의 일정을 모든 매장에 생성") {
val stores: List<Pair<Long, Long>> = getStoreWithManagers() val stores: List<Pair<Long, Long>> = getStoreWithManagers()
val themes: List<Triple<Long, Short, LocalDateTime>> = getThemes() val themes: List<ThemeEntity> = getThemes()
val maxAvailableMinutes = themes.maxOf { it.second.toInt() } val maxScheduleCountPerDay = 10
val scheduleCountPerDay = 5
val startTime = LocalTime.of(10, 0) val startTime = LocalTime.of(10, 0)
var lastTime = startTime
val times = mutableListOf<LocalTime>()
repeat(scheduleCountPerDay) { val themeWithTimes: Map<ThemeEntity, List<LocalTime>> = themes.associateWith { theme ->
times.add(lastTime) val times = mutableListOf<LocalTime>()
lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L) val themeAvailableMinutes = theme.availableMinutes
var lastTime = startTime
while (times.size <= maxScheduleCountPerDay && lastTime.hour in (10..23)) {
times.add(lastTime)
lastTime = lastTime.plusMinutes(themeAvailableMinutes + 10L)
}
times
} }
coroutineScope { coroutineScope {
themes.forEach { theme -> stores.map { store ->
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
processTheme(theme, stores, times) processTheme(store, themeWithTimes)
}
}
}
}
test("내일 ~ 일주일 뒤 까지의 일정 생성") {
// val stores: List<Pair<Long, Long>> = getStoreWithManagers()
// val availableThemes: List<ThemeEntity> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
// entityManager.createQuery(
// "SELECT t FROM ThemeEntity t WHERE t.isActive = true AND t.createdAt >", ThemeEntity::class.java
// ).resultList
// }!!.take(10)
coroutineScope {
val jobs = (1..100).map { i ->
launch(Dispatchers.IO) {
val threadName = Thread.currentThread().name
println("[$i] 시작: $threadName")
delay(1)
println("[$i] 완료: $threadName")
} }
} }
} }
@ -444,9 +469,8 @@ class ScheduleDataInitializer : AbstractDataInitializer() {
} }
private suspend fun processTheme( private suspend fun processTheme(
theme: Triple<Long, Short, LocalDateTime>, store: Pair<Long, Long>,
stores: List<Pair<Long, Long>>, themeWithTimes: Map<ThemeEntity, List<LocalTime>>
times: List<LocalTime>
) { ) {
val sql = """ val sql = """
INSERT INTO schedule ( INSERT INTO schedule (
@ -457,24 +481,33 @@ class ScheduleDataInitializer : AbstractDataInitializer() {
val batchArgs = mutableListOf<Array<Any>>() val batchArgs = mutableListOf<Array<Any>>()
val now = LocalDateTime.now() val status = ScheduleStatus.RESERVED.name
stores.forEach { (storeId, adminId) -> themeWithTimes.forEach { (theme, times) ->
(1..3).forEach { dayOffset -> val themeCreatedAt = theme.createdAt
val date = theme.third.toLocalDate().plusDays(dayOffset.toLong()) (1..3).forEach {
val themeCreatedDateTime = themeCreatedAt.atZone(ZoneId.systemDefault())
val themeCreatedDate = themeCreatedDateTime.toLocalDate().plusDays(it.toLong())
val themeCreatedTime = themeCreatedDateTime.toLocalTime()
times.forEach { time -> times.forEach { time ->
val scheduledAt = LocalDateTime.of(date, time) val storeId = store.first
val status = val storeAdminId = store.second
if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name
batchArgs.add( batchArgs.add(
arrayOf( arrayOf(
idGenerator.create(), storeId, theme.first, date, time, idGenerator.create(),
status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now) storeId,
theme.id,
themeCreatedDate,
time,
status,
storeAdminId,
storeAdminId,
themeCreatedTime.plusHours(1),
themeCreatedTime.plusHours(1)
) )
) )
if (batchArgs.size >= 500) { if (batchArgs.size >= 300) {
executeBatch(sql, batchArgs).also { batchArgs.clear() } executeBatch(sql, batchArgs).also { batchArgs.clear() }
} }
} }
@ -500,17 +533,13 @@ class ScheduleDataInitializer : AbstractDataInitializer() {
} }
} }
private fun getThemes(): List<Triple<Long, Short, LocalDateTime>> { private fun getThemes(): List<ThemeEntity> {
return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { return transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery( entityManager.createQuery(
"SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t", "SELECT t FROM ThemeEntity t",
List::class.java ThemeEntity::class.java
) ).resultList
.resultList }!!
}!!.map {
val array = it as List<*>
Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime)
}
} }
} }
@ -528,10 +557,10 @@ class ReservationDataInitializer : AbstractDataInitializer() {
init { init {
context("예약 초기 데이터 생성") { context("예약 초기 데이터 생성") {
test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") { test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") {
val chunkSize = 10_000 val chunkSize = 500
val chunkedSchedules: List<List<ScheduleWithThemeParticipants>> = entityManager.createQuery( val chunkedSchedules: List<List<ScheduleWithThemeParticipants>> = entityManager.createQuery(
"SELECT new com.sangdol.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status", "SELECT new com.sangdol.roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status",
ScheduleWithThemeParticipants::class.java ScheduleWithThemeParticipants::class.java
).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize) ).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize)
@ -587,10 +616,6 @@ class ReservationDataInitializer : AbstractDataInitializer() {
user.id, user.id,
) )
) )
if (batchArgs.size >= 1_000) {
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
} }
if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() }
@ -613,8 +638,9 @@ data class PaymentWithMethods(
class PaymentDataInitializer : AbstractDataInitializer() { class PaymentDataInitializer : AbstractDataInitializer() {
companion object { companion object {
val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime()) val requestedAtCache: Timestamp = Timestamp.valueOf(KoreaDateTime.nowWithOffset().toLocalDateTime())
val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime()) val approvedAtCache: Timestamp =
Timestamp.valueOf(KoreaDateTime.nowWithOffset().plusSeconds(5).toLocalDateTime())
val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD) val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD)
val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK) val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK)
@ -671,7 +697,7 @@ class PaymentDataInitializer : AbstractDataInitializer() {
} }
coroutineScope { coroutineScope {
allReservations.chunked(10_000).forEach { reservations -> allReservations.chunked(500).forEach { reservations ->
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
processPaymentAndDefaultDetail(reservations) processPaymentAndDefaultDetail(reservations)
} }
@ -681,12 +707,12 @@ class PaymentDataInitializer : AbstractDataInitializer() {
test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") { test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") {
val allPayments: List<PaymentWithMethods> = entityManager.createQuery( val allPayments: List<PaymentWithMethods> = entityManager.createQuery(
"SELECT new com.sangdol.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId", "SELECT new com.sangdol.roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId",
PaymentWithMethods::class.java PaymentWithMethods::class.java
).resultList ).resultList
coroutineScope { coroutineScope {
allPayments.chunked(10_000).forEach { payments -> allPayments.chunked(500).forEach { payments ->
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
processPaymentDetail(payments) processPaymentDetail(payments)
} }
@ -731,9 +757,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
approvedAtCache, approvedAtCache,
) )
) )
if (paymentBatchArgs.size >= 1_000) {
executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() }
}
val suppliedAmount: Int = (totalPrice * 0.9).toInt() val suppliedAmount: Int = (totalPrice * 0.9).toInt()
val vat: Int = (totalPrice - suppliedAmount) val vat: Int = (totalPrice - suppliedAmount)
@ -746,10 +769,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
vat vat
) )
) )
if (detailBatchArgs.size >= 1_000) {
executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() }
}
} }
if (paymentBatchArgs.isNotEmpty()) { if (paymentBatchArgs.isNotEmpty()) {
@ -780,9 +799,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
settlementStatus settlementStatus
) )
) )
if (transferBatchArgs.size >= 1_000) {
executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() }
}
} }
PaymentMethod.EASY_PAY -> { PaymentMethod.EASY_PAY -> {
@ -803,10 +819,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
) )
) )
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
} else { } else {
easypayPrepaidBatchArgs.add( easypayPrepaidBatchArgs.add(
arrayOf( arrayOf(
@ -816,10 +828,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
randomDiscountAmount, randomDiscountAmount,
) )
) )
if (easypayPrepaidBatchArgs.size >= 1_000) {
executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() }
}
} }
} }
@ -839,10 +847,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
0, 0,
) )
) )
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
} }
else -> return@forEach else -> return@forEach
@ -855,7 +859,10 @@ class PaymentDataInitializer : AbstractDataInitializer() {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
} }
if (easypayPrepaidBatchArgs.isNotEmpty()) { if (easypayPrepaidBatchArgs.isNotEmpty()) {
executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } executeBatch(
paymentEasypayPrepaidDetailSql,
easypayPrepaidBatchArgs
).also { easypayPrepaidBatchArgs.clear() }
} }
} }

View File

@ -129,7 +129,12 @@ class StoreDataInitializer {
val randomPositiveWord = positiveWords.random() val randomPositiveWord = positiveWords.random()
storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}" storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}"
address = address =
"${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}${Random.nextInt(1, 100)}" "${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${
Random.nextInt(
1,
10
)
} ${Random.nextInt(1, 100)}"
} while (usedStoreName.contains(storeName)) } while (usedStoreName.contains(storeName))
usedStoreName.add(storeName) usedStoreName.add(storeName)
@ -158,13 +163,17 @@ class StoreDataInitializer {
} }
File("$BASE_DIR/store_data.txt").also { File("$BASE_DIR/store_data.txt").also {
if (it.exists()) { it.delete() } if (it.exists()) {
it.delete()
}
}.writeText( }.writeText(
storeDataRows.joinToString("\n") storeDataRows.joinToString("\n")
) )
return File("$BASE_DIR/store_data.sql").also { return File("$BASE_DIR/store_data.sql").also {
if (it.exists()) { it.delete() } if (it.exists()) {
it.delete()
}
StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, status, created_at, created_by, updated_at, updated_by) VALUES ") StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, status, created_at, created_by, updated_at, updated_by) VALUES ")
.append(storeSqlRows.joinToString(",\n")) .append(storeSqlRows.joinToString(",\n"))
@ -195,8 +204,7 @@ private fun randomLocalDateTime(): String {
return LocalDateTime.of(year, month, day, hour, minute, second) return LocalDateTime.of(year, month, day, hour, minute, second)
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.toOffsetDateTime() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
} }
private fun generateBusinessRegNum(): String { private fun generateBusinessRegNum(): String {

View File

@ -1,13 +1,6 @@
package com.sangdol.roomescape.auth package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.restassured.response.ValidatableResponse
import org.hamcrest.CoreMatchers.equalTo
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.admin.exception.AdminErrorCode import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
@ -24,6 +17,13 @@ import com.sangdol.roomescape.supports.UserFixture
import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.supports.runTest
import com.sangdol.roomescape.user.exception.UserErrorCode import com.sangdol.roomescape.user.exception.UserErrorCode
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.restassured.response.ValidatableResponse
import org.hamcrest.CoreMatchers.equalTo
class AuthApiTest( class AuthApiTest(
@SpykBean private val jwtUtils: JwtUtils, @SpykBean private val jwtUtils: JwtUtils,

View File

@ -1,8 +1,6 @@
package com.sangdol.roomescape.auth package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import io.mockk.clearMocks
import io.mockk.every
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.web.LoginRequest import com.sangdol.roomescape.auth.web.LoginRequest
@ -11,6 +9,8 @@ import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.UserFixture import com.sangdol.roomescape.supports.UserFixture
import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.supports.runTest
import io.mockk.clearMocks
import io.mockk.every
class FailOnSaveLoginHistoryTest( class FailOnSaveLoginHistoryTest(
@MockkBean private val loginHistoryRepository: LoginHistoryRepository @MockkBean private val loginHistoryRepository: LoginHistoryRepository

View File

@ -1,10 +1,6 @@
package com.sangdol.roomescape.payment package com.sangdol.roomescape.payment
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import io.kotest.matchers.shouldBe
import io.mockk.every
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
@ -18,6 +14,10 @@ import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
import com.sangdol.roomescape.payment.web.PaymentCreateResponse import com.sangdol.roomescape.payment.web.PaymentCreateResponse
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import io.kotest.matchers.shouldBe
import io.mockk.every
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
class PaymentAPITest( class PaymentAPITest(
@MockkBean @MockkBean

View File

@ -2,14 +2,7 @@ package com.sangdol.roomescape.payment
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.common.BankCode import com.sangdol.roomescape.payment.infrastructure.common.*
import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode
import com.sangdol.roomescape.payment.infrastructure.common.CardOwnerType
import com.sangdol.roomescape.payment.infrastructure.common.CardType
import com.sangdol.roomescape.payment.infrastructure.common.EasyPayCompanyCode
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 io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe

View File

@ -1,6 +1,6 @@
package com.sangdol.roomescape.payment package com.sangdol.roomescape.payment
import java.time.OffsetDateTime import com.sangdol.common.utils.KoreaDateTime
object SampleTosspayConstant { object SampleTosspayConstant {
const val PAYMENT_KEY: String = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1" const val PAYMENT_KEY: String = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1"
@ -39,8 +39,8 @@ object SampleTosspayConstant {
"orderName": "Sonya Aguirre 예약 결제", "orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"status": "DONE", "status": "DONE",
"requestedAt": "${OffsetDateTime.now()}", "requestedAt": "${KoreaDateTime.nowWithOffset()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}", "approvedAt": "${KoreaDateTime.nowWithOffset().plusSeconds(5)}",
"useEscrow": false, "useEscrow": false,
"cultureExpense": false, "cultureExpense": false,
"card": { "card": {
@ -102,8 +102,8 @@ object SampleTosspayConstant {
"orderName": "Sonya Aguirre 예약 결제", "orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"status": "CANCELED", "status": "CANCELED",
"requestedAt": "${OffsetDateTime.now()}", "requestedAt": "${KoreaDateTime.nowWithOffset()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}", "approvedAt": "${KoreaDateTime.nowWithOffset().plusSeconds(5)}",
"useEscrow": false, "useEscrow": false,
"cultureExpense": false, "cultureExpense": false,
"card": { "card": {
@ -132,7 +132,7 @@ object SampleTosspayConstant {
"transactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr", "transactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr",
"cancelReason": "$CANCEL_REASON", "cancelReason": "$CANCEL_REASON",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"canceledAt": "${OffsetDateTime.now().plusMinutes(1)}", "canceledAt": "${KoreaDateTime.nowWithOffset().plusMinutes(1)}",
"cardDiscountAmount": 0, "cardDiscountAmount": 0,
"transferDiscountAmount": 0, "transferDiscountAmount": 0,
"easyPayDiscountAmount": 0, "easyPayDiscountAmount": 0,
@ -181,8 +181,8 @@ object SampleTosspayConstant {
"orderName": "Sonya Aguirre 예약 결제", "orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"status": "DONE", "status": "DONE",
"requestedAt": "${OffsetDateTime.now()}", "requestedAt": "${KoreaDateTime.nowWithOffset()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}", "approvedAt": "${KoreaDateTime.nowWithOffset().plusSeconds(5)}",
"useEscrow": false, "useEscrow": false,
"cultureExpense": false, "cultureExpense": false,
"card": null, "card": null,
@ -230,8 +230,8 @@ object SampleTosspayConstant {
"orderName": "Sonya Aguirre 예약 결제", "orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"status": "DONE", "status": "DONE",
"requestedAt": "${OffsetDateTime.now()}", "requestedAt": "${KoreaDateTime.nowWithOffset()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}", "approvedAt": "${KoreaDateTime.nowWithOffset().plusSeconds(5)}",
"useEscrow": false, "useEscrow": false,
"cultureExpense": false, "cultureExpense": false,
"card": null, "card": null,
@ -250,7 +250,7 @@ object SampleTosspayConstant {
"transactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr", "transactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr",
"cancelReason": "$CANCEL_REASON", "cancelReason": "$CANCEL_REASON",
"taxExemptionAmount": 0, "taxExemptionAmount": 0,
"canceledAt": "${OffsetDateTime.now().plusMinutes(1)}", "canceledAt": "${KoreaDateTime.nowWithOffset().plusMinutes(1)}",
"cardDiscountAmount": 0, "cardDiscountAmount": 0,
"transferDiscountAmount": 0, "transferDiscountAmount": 0,
"easyPayDiscountAmount": 0, "easyPayDiscountAmount": 0,

View File

@ -1,6 +1,12 @@
package com.sangdol.roomescape.payment package com.sangdol.roomescape.payment
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@ -9,19 +15,13 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseActions import org.springframework.test.web.client.ResponseActions
import org.springframework.test.web.client.match.MockRestRequestMatchers.* import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import org.springframework.http.HttpStatus
@RestClientTest(TosspayClient::class) @RestClientTest(TosspayClient::class)
@MockkBean(JpaMetamodelMappingContext::class) @MockkBean(JpaMetamodelMappingContext::class)

View File

@ -1,16 +1,16 @@
package com.sangdol.roomescape.region package com.sangdol.roomescape.region
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.springframework.http.HttpMethod
import com.sangdol.roomescape.region.exception.RegionErrorCode import com.sangdol.roomescape.region.exception.RegionErrorCode
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.runExceptionTest import com.sangdol.roomescape.supports.runExceptionTest
import io.mockk.every
import org.springframework.http.HttpMethod
class RegionApiFailTest( class RegionApiFailTest(
@MockkBean private val regionRepository: RegionRepository @MockkBean private val regionRepository: RegionRepository
): FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
context("조회 실패") { context("조회 실패") {
test("시/도") { test("시/도") {

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.region package com.sangdol.roomescape.region
import io.kotest.matchers.shouldBe
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.supports.runTest
import io.kotest.matchers.shouldBe
class RegionApiSuccessTest: FunSpecSpringbootTest() { class RegionApiSuccessTest : FunSpecSpringbootTest() {
init { init {
context("시/도 -> 시/군/구 -> 지역 코드 순으로 조회한다.") { context("시/도 -> 시/군/구 -> 지역 코드 순으로 조회한다.") {
test("정상 응답") { test("정상 응답") {

View File

@ -13,7 +13,8 @@ import io.kotest.assertions.assertSoftly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import java.time.LocalDateTime import java.time.Instant
import java.time.temporal.ChronoUnit
/** /**
* @see com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler * @see com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler
@ -31,7 +32,7 @@ class IncompletedReservationSchedulerTest(
test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") {
val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply { val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply {
this.status = ScheduleStatus.HOLD this.status = ScheduleStatus.HOLD
this.holdExpiredAt = LocalDateTime.now().minusSeconds(1) this.holdExpiredAt = Instant.now().minusSeconds(1)
}.also { }.also {
scheduleRepository.saveAndFlush(it) scheduleRepository.saveAndFlush(it)
} }
@ -52,16 +53,13 @@ class IncompletedReservationSchedulerTest(
jdbcTemplate.execute("UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 5 MINUTE) WHERE id = ${it.id}") jdbcTemplate.execute("UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 5 MINUTE) WHERE id = ${it.id}")
} }
val now = LocalDateTime.now()
transactionExecutionUtil.withNewTransaction(isReadOnly = false) { transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
incompletedReservationScheduler.processExpiredReservation() incompletedReservationScheduler.processExpiredReservation()
} }
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldBe ReservationStatus.EXPIRED this.status shouldBe ReservationStatus.EXPIRED
this.updatedAt.hour shouldBe now.hour this.updatedAt.truncatedTo(ChronoUnit.MINUTES) shouldBe Instant.now().truncatedTo(ChronoUnit.MINUTES)
this.updatedAt.minute shouldBe now.minute
} }
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {

View File

@ -1,13 +1,8 @@
package com.sangdol.roomescape.reservation package com.sangdol.roomescape.reservation
import io.kotest.matchers.shouldBe import com.sangdol.common.types.exception.CommonErrorCode
import io.kotest.matchers.shouldNotBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.roomescape.payment.infrastructure.common.BankCode import com.sangdol.roomescape.payment.infrastructure.common.BankCode
import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode
import com.sangdol.roomescape.payment.infrastructure.common.EasyPayCompanyCode import com.sangdol.roomescape.payment.infrastructure.common.EasyPayCompanyCode
@ -25,6 +20,11 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -574,7 +574,10 @@ class ReservationApiTest(
expect = { expect = {
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
assertProperties(props = setOf("id", "reserver", "user", "applicationDateTime", "payment")) assertProperties(props = setOf("id", "reserver", "user", "applicationDateTime", "payment"))
assertProperties(props = setOf("name", "contact", "participantCount", "requirement"), propsNameIfList = "reserver") assertProperties(
props = setOf("name", "contact", "participantCount", "requirement"),
propsNameIfList = "reserver"
)
assertProperties(props = setOf("id", "name", "phone"), propsNameIfList = "user") assertProperties(props = setOf("id", "name", "phone"), propsNameIfList = "user")
} }
).also { ).also {

View File

@ -20,7 +20,7 @@ import kotlinx.coroutines.withContext
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime import java.time.Instant
class ReservationConcurrencyTest( class ReservationConcurrencyTest(
private val transactionManager: PlatformTransactionManager, private val transactionManager: PlatformTransactionManager,
@ -35,7 +35,7 @@ class ReservationConcurrencyTest(
val user = testAuthUtil.defaultUserLogin().first val user = testAuthUtil.defaultUserLogin().first
val schedule = dummyInitializer.createSchedule().also { val schedule = dummyInitializer.createSchedule().also {
it.status = ScheduleStatus.HOLD it.status = ScheduleStatus.HOLD
it.holdExpiredAt = LocalDateTime.now().minusMinutes(1) it.holdExpiredAt = Instant.now().minusSeconds(1 * 60)
scheduleRepository.save(it) scheduleRepository.save(it)
} }
lateinit var response: PendingReservationCreateResponse lateinit var response: PendingReservationCreateResponse

View File

@ -1,10 +1,13 @@
package com.sangdol.roomescape.schedule package com.sangdol.roomescape.schedule
import com.sangdol.roomescape.common.types.Auditor
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.common.types.Auditor
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
@ -54,7 +57,8 @@ class AdminScheduleApiTest(
lateinit var token: String lateinit var token: String
beforeTest { beforeTest {
val today = LocalDate.now() val now = KoreaDateTime.now()
val today = now.toLocalDate()
store = dummyInitializer.createStore() store = dummyInitializer.createStore()
val admin = AdminFixture.createStoreAdmin(storeId = store.id) val admin = AdminFixture.createStoreAdmin(storeId = store.id)
token = testAuthUtil.adminLogin(admin).second token = testAuthUtil.adminLogin(admin).second
@ -66,21 +70,21 @@ class AdminScheduleApiTest(
storeId = store.id, storeId = store.id,
request = ScheduleFixture.createRequest.copy( request = ScheduleFixture.createRequest.copy(
date = today, date = today,
time = LocalTime.now().plusHours(2) time = now.toLocalTime().plusHours(2)
) )
), ),
dummyInitializer.createSchedule( dummyInitializer.createSchedule(
storeId = store.id, storeId = store.id,
request = ScheduleFixture.createRequest.copy( request = ScheduleFixture.createRequest.copy(
date = today, date = today,
time = LocalTime.now().plusHours(1) time = now.toLocalTime().plusHours(1)
) )
), ),
dummyInitializer.createSchedule( dummyInitializer.createSchedule(
storeId = store.id, storeId = store.id,
request = ScheduleFixture.createRequest.copy( request = ScheduleFixture.createRequest.copy(
date = today.plusDays(1), date = today.plusDays(1),
time = LocalTime.of(11, 0) time = LocalTime.of(10, 0)
) )
) )
) )
@ -95,7 +99,10 @@ class AdminScheduleApiTest(
}, },
expect = { expect = {
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
body("data.schedules.size()", equalTo(schedules.filter { it.date.isEqual(LocalDate.now()) }.size)) body(
"data.schedules.size()",
equalTo(schedules.filter { it.date.isEqual(KoreaDate.today()) }.size)
)
assertProperties( assertProperties(
props = setOf("id", "themeName", "startFrom", "endAt", "status"), props = setOf("id", "themeName", "startFrom", "endAt", "status"),
propsNameIfList = "schedules" propsNameIfList = "schedules"
@ -386,8 +393,9 @@ class AdminScheduleApiTest(
test("과거 시간을 선택하면 실패한다.") { test("과거 시간을 선택하면 실패한다.") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin() val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
val date = LocalDate.now() val now = KoreaDateTime.now()
val time = LocalTime.now().minusMinutes(1) val date = now.toLocalDate()
val time = now.toLocalTime().minusMinutes(1)
val theme = dummyInitializer.createTheme() val theme = dummyInitializer.createTheme()
val request = ScheduleFixture.createRequest.copy(date = date, time = time, themeId = theme.id) val request = ScheduleFixture.createRequest.copy(date = date, time = time, themeId = theme.id)
@ -490,29 +498,50 @@ class AdminScheduleApiTest(
} }
context("정상 응답") { context("정상 응답") {
test("시간만 변경한다.") { context("시간만 변경한다.") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin() test("성공") {
val schedule = initialize("수정을 위한 일정 생성") { val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
dummyInitializer.createSchedule() val schedule = initialize("수정을 위한 일정 생성") {
} dummyInitializer.createSchedule()
val updateTime = schedule.time.plusHours(1)
runTest(
token = token,
using = {
body(ScheduleUpdateRequest(time = updateTime))
},
on = {
patch("/admin/schedules/${schedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
} }
).also { val updateTime = schedule.time.plusHours(1)
val updated = scheduleRepository.findByIdOrNull(schedule.id)!!
updated.time shouldBe updateTime runTest(
updated.status shouldBe schedule.status token = token,
updated.updatedAt shouldNotBe schedule.updatedAt using = {
body(ScheduleUpdateRequest(time = updateTime))
},
on = {
patch("/admin/schedules/${schedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val updated = scheduleRepository.findByIdOrNull(schedule.id)!!
updated.time shouldBe updateTime
updated.status shouldBe schedule.status
updated.updatedAt shouldNotBe schedule.updatedAt
}
}
test("지난 시간을 선택하면 실패한다.") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
val schedule = initialize("수정을 위한 일정 생성") {
val request = ScheduleFixture.createRequest.copy(
date = KoreaDate.today(),
time = KoreaTime.now().plusHours(1)
)
dummyInitializer.createSchedule(request = request)
}
runExceptionTest(
token = token,
method = HttpMethod.PATCH,
endpoint = "/admin/schedules/${schedule.id}",
requestBody = ScheduleUpdateRequest(time = KoreaTime.now().minusMinutes(1)),
expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME
)
} }
} }

View File

@ -1,6 +1,8 @@
package com.sangdol.roomescape.schedule package com.sangdol.roomescape.schedule
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
@ -13,16 +15,18 @@ import org.hamcrest.CoreMatchers.equalTo
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime
class ScheduleApiTest( class ScheduleApiTest(
private val scheduleRepository: ScheduleRepository private val scheduleRepository: ScheduleRepository
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") { context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") {
/**
* @throws 23 57 ~ 59분에 실행하면 실패함.
*/
test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") { test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") {
val size = 3 val size = 3
val date = LocalDate.now() val date = KoreaDate.today()
val store = dummyInitializer.createStore() val store = dummyInitializer.createStore()
initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") { initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") {
@ -31,7 +35,7 @@ class ScheduleApiTest(
storeId = store.id, storeId = store.id,
request = ScheduleFixture.createRequest.copy( request = ScheduleFixture.createRequest.copy(
date = date, date = date,
time = LocalTime.now().plusMinutes(i.toLong()) time = KoreaTime.now().plusMinutes(i.toLong())
) )
) )
} }
@ -40,24 +44,28 @@ class ScheduleApiTest(
storeId = store.id, storeId = store.id,
request = ScheduleFixture.createRequest.copy( request = ScheduleFixture.createRequest.copy(
date = date, date = date,
time = LocalTime.now().minusMinutes(1) time = KoreaTime.now().minusMinutes(1)
) )
) )
} }
val expectedSize = scheduleRepository.findAll().takeIf { it.isNotEmpty() }
?.let { it.count { schedule -> schedule.date.isEqual(date) && schedule.time.isAfter(LocalTime.now()) } }
?: throw AssertionError("initialize 작업에서 레코드가 저장되지 않음.")
runTest( runTest(
on = { on = {
get("/stores/${store.id}/schedules?date=${date}") get("/stores/${store.id}/schedules?date=${date}")
}, },
expect = { expect = {
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
body("data.schedules.size()", equalTo(expectedSize)) body("data.schedules.size()", equalTo(size))
assertProperties( assertProperties(
props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"), props = setOf(
"id",
"startFrom",
"endAt",
"themeId",
"themeName",
"themeDifficulty",
"status"
),
propsNameIfList = "schedules" propsNameIfList = "schedules"
) )
} }

View File

@ -7,8 +7,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.supports.runTest
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@ -1,10 +1,5 @@
package com.sangdol.roomescape.store package com.sangdol.roomescape.store
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.date.shouldBeAfter
import io.kotest.matchers.shouldBe
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
@ -16,6 +11,11 @@ import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
import com.sangdol.roomescape.store.web.StoreUpdateRequest import com.sangdol.roomescape.store.web.StoreUpdateRequest
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.date.shouldBeAfter
import io.kotest.matchers.shouldBe
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
class AdminStoreApiTest( class AdminStoreApiTest(
private val storeRepository: StoreRepository, private val storeRepository: StoreRepository,

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