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

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

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 애플리케이션 배포
- 1차 배포에서 각 Service의 Trace가 구분이 되지 않아 XxxService 클래스에 \@Observation을 적용하는 AOP 추가
- 불필요하게 느껴지는 Prometheus Actuator 요청과 스케쥴링 작업 Tracing 제외
- 애플리케이션이 UTC로 배포됨에 따라 발생하는 문제 해결을 위해 LocalDateTime, OffsetDateTime -> Instant 타입 변경 및 LocalDate, LocalTime은 KST로 비교하도록 수정
- 기존 로그의 가독성이 좋지 않아, 로그 메시지가 가장 먼저 보이도록 형식 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 실제 웹에 접속하여 전체적인 기능 점검
- 예약 처리 로직에서 미숙한 부분이 발견되어 다음 작업은 예약 처리 로직 개선 예정

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

Reviewed-on: #55
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-10-06 02:42:13 +00:00 committed by 이상진
parent 186d6e118c
commit 8215492eea
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
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 . .
RUN ./gradlew bootjar --no-daemon
RUN ./gradlew :service:bootjar --no-daemon
FROM amazoncorretto:17
WORKDIR /app
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"]

View File

@ -7,7 +7,6 @@ import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
@ -48,7 +47,10 @@ class AbstractLogMaskingConverterTest : FunSpec({
event.formattedMessage
} returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format("${account.first()}${converter.mask}${account.last()}", "${address.first()}${converter.mask}${address.last()}")
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.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
lateinit var createdAt: Instant
@Column(updatable = false)
@CreatedBy
@ -25,7 +25,7 @@ abstract class AuditingBaseEntity(
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
lateinit var updatedAt: Instant
@Column
@LastModifiedBy

View File

@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository
class TestAuditingBaseEntity(
id: Long,
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(
id: Long,
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.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import io.mockk.*
import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager
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
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
@ -15,19 +12,13 @@ import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
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 =
DateTimeFormatter.ofPattern("HH:mm")
}
@ -35,9 +26,9 @@ class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(dateTimeModule())
.registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
@ -56,35 +47,4 @@ class JacksonConfig {
LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) 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)
}
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)
.endpoint()
.principalId()

View File

@ -7,10 +7,7 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class JacksonConfigTest : FunSpec({
@ -55,38 +52,4 @@ class JacksonConfigTest : FunSpec({
}.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 {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}

View File

@ -29,7 +29,7 @@ class WebLogMessageConverterTest : FunSpec({
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
@ -121,7 +121,10 @@ class WebLogMessageConverterTest : FunSpec({
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message)
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["principal_id"] shouldBe principalId
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:
MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local
TZ: Asia/Seoul
TZ: UTC
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

@ -1,6 +1,18 @@
node_modules
.git
.DS_Store
.gitignore
# Node.js
node_modules
npm-debug.log
dist
# Build output
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 AS builder
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --frozen-lockfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# Stage 2: Serve with Nginx
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,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 {getStores} from '@_api/store/storeAPI';
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 {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-schedule-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
@ -53,8 +54,8 @@ const AdminSchedulePage: React.FC = () => {
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false);
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
@ -332,10 +333,10 @@ const AdminSchedulePage: React.FC = () => {
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
</p>
<p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
</p>
<p>
<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 { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import type { SidoResponse, SigunguResponse } from '@_api/region/regionTypes';
import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
import {
type SimpleStoreResponse,
type StoreDetailResponse,
type StoreRegisterRequest,
type UpdateStoreRequest
} from '@_api/store/storeTypes';
import { useAdminAuth } from '@_context/AdminAuthContext';
import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-store-page.css';
import React, { Fragment, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
const AdminStorePage: React.FC = () => {
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
@ -297,10 +298,10 @@ const AdminStorePage: React.FC = () => {
:</strong> {detailedStores[store.id].region.code}
</p>
<p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
</p>
<p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
</p>
<p>
<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 {useLocation, useNavigate, useParams} from 'react-router-dom';
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 {
name: string;
@ -256,8 +257,8 @@ const AdminThemeEditPage: React.FC = () => {
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p><strong>:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
<p><strong>:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
<p><strong>:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
<p><strong>:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
<p><strong>:</strong> {auditInfo.createdBy.name}</p>
<p><strong>:</strong> {auditInfo.updatedBy.name}</p>
</div>

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ class AuthService(
request: LoginRequest,
context: LoginContext
): 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)
try {
@ -40,7 +40,7 @@ class AuthService(
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
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) {
@ -48,12 +48,12 @@ class AuthService(
when (e) {
is AuthException -> {
log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" }
log.info { "[login] 로그인 실패: account = ${request.account}" }
throw e
}
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)
}
}
@ -65,7 +65,7 @@ class AuthService(
credentials: LoginCredentials
) {
if (credentials.password != request.password) {
log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED)
}
}

View File

@ -42,7 +42,7 @@ class LoginHistoryService(
success: Boolean,
context: LoginContext
) {
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
runCatching {
LoginHistoryEntity(
@ -54,10 +54,10 @@ class LoginHistoryService(
userAgent = context.userAgent,
).also {
loginHistoryRepository.save(it)
log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
}
}.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
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.LoginRequest
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses

View File

@ -1,7 +1,7 @@
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.web.HttpStatus
enum class AuthErrorCode(
override val httpStatus: HttpStatus,

View File

@ -1,5 +1,7 @@
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.KotlinLogging
import io.jsonwebtoken.Claims
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import java.util.*
import javax.crypto.SecretKey

View File

@ -5,7 +5,7 @@ import com.sangdol.roomescape.auth.web.PrincipalType
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
@Entity
@Table(name = "login_history")
@ -24,5 +24,5 @@ class LoginHistoryEntity(
@Column(updatable = false)
@CreatedDate
var createdAt: LocalDateTime? = null,
var createdAt: Instant? = null,
) : PersistableBaseEntity(id)

View File

@ -1,6 +1,5 @@
package com.sangdol.roomescape.auth.web
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import jakarta.servlet.http.HttpServletRequest
enum class PrincipalType {

View File

@ -1,12 +1,6 @@
package com.sangdol.roomescape.auth.web.support.interceptors
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
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
@ -17,7 +11,13 @@ import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.accessToken
import 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 {}
@ -47,7 +47,10 @@ class AdminInterceptor(
return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
is AuthException -> {
throw e
}
else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,12 @@
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.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -7,13 +14,6 @@ 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.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 {}
@ -47,7 +47,10 @@ class UserInterceptor(
return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
is AuthException -> {
throw e
}
else -> {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,11 @@
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.KotlinLogging
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.method.support.HandlerMethodArgumentResolver
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 {}

View File

@ -1,9 +1,9 @@
package com.sangdol.roomescape.common.config
import com.sangdol.common.web.config.JacksonConfig
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"),
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.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Profile
import javax.sql.DataSource
@Configuration
@Profile("deploy")
@EnableConfigurationProperties(SlowQueryProperties::class)
class ProxyDataSourceConfig {
@ -36,7 +34,6 @@ class ProxyDataSourceConfig {
.build()
}
@Profile("deploy")
@ConfigurationProperties(prefix = "slow-query")
data class SlowQueryProperties(
val loggerName: String,

View File

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

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
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.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
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
class WebMvcConfig(

View File

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

View File

@ -1,9 +1,5 @@
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.roomescape.payment.exception.PaymentErrorCode
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.persistence.*
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 {}
@ -42,7 +42,7 @@ class PaymentService(
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
} ?: run {
log.warn { "[PaymentService.confirm] 결제 확정 중 예상치 못한 null 반환" }
log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
@ -64,13 +64,13 @@ class PaymentService(
cancelResponse = clientCancelResponse
)
}.also {
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" }
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
}
@Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
log.info { "[PaymentService.findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
@ -83,49 +83,49 @@ class PaymentService(
}
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $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 {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
.also {
if (it != null) {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" }
} else {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
log.warn { "[findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
}
}
}
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" }
} else {
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
log.warn { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
}
}
}
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) {
log.info { "[PaymentService.findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
log.info { "[findDetailByReservationId] 취소 결제 정보가 없음: paymentId=${paymentId}" }
} 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.KotlinLogging
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@ -60,7 +60,7 @@ class PaymentWriter(
fun cancel(
userId: Long,
payment: PaymentEntity,
requestedAt: LocalDateTime,
requestedAt: Instant,
cancelResponse: PaymentClientCancelResponse
): CanceledPaymentEntity {
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }

View File

@ -1,7 +1,7 @@
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.web.HttpStatus
enum class PaymentErrorCode(
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.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import java.time.LocalDateTime
import java.time.Instant
import java.time.OffsetDateTime
data class PaymentClientCancelResponse(
@ -28,10 +28,10 @@ fun CancelDetail.toEntity(
id: Long,
paymentId: Long,
canceledBy: Long,
cancelRequestedAt: LocalDateTime
cancelRequestedAt: Instant
) = CanceledPaymentEntity(
id = id,
canceledAt = this.canceledAt,
canceledAt = this.canceledAt.toInstant(),
requestedAt = cancelRequestedAt,
paymentId = paymentId,
canceledBy = canceledBy,

View File

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

View File

@ -1,10 +1,10 @@
package com.sangdol.roomescape.payment.infrastructure.common
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.PaymentException
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
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 jakarta.persistence.Entity
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.Instant
@Entity
@Table(name = "canceled_payment")
@ -12,8 +11,8 @@ class CanceledPaymentEntity(
id: Long,
val paymentId: Long,
val requestedAt: LocalDateTime,
val canceledAt: OffsetDateTime,
val requestedAt: Instant,
val canceledAt: Instant,
val canceledBy: Long,
val cancelReason: String,
val cancelAmount: Int,

View File

@ -8,7 +8,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.time.Instant
@Entity
@Table(name = "payment")
@ -19,8 +19,8 @@ class PaymentEntity(
val paymentKey: String,
val orderId: String,
val totalAmount: Int,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val requestedAt: Instant,
val approvedAt: Instant,
@Enumerated(EnumType.STRING)
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.persistence.*
import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.Instant
data class PaymentConfirmRequest(
val paymentKey: String,
@ -24,7 +23,7 @@ data class PaymentCreateResponse(
data class PaymentCancelRequest(
val reservationId: Long,
val cancelReason: String,
val requestedAt: LocalDateTime = LocalDateTime.now()
val requestedAt: Instant = Instant.now()
)
data class PaymentWithDetailResponse(
@ -32,8 +31,8 @@ data class PaymentWithDetailResponse(
val totalAmount: Int,
val method: String,
val status: PaymentStatus,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val requestedAt: Instant,
val approvedAt: Instant,
val detail: PaymentDetailResponse?,
val cancel: PaymentCancelDetailResponse?,
)
@ -120,8 +119,8 @@ fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayP
}
data class PaymentCancelDetailResponse(
val cancellationRequestedAt: LocalDateTime,
val cancellationApprovedAt: OffsetDateTime?,
val cancellationRequestedAt: Instant,
val cancellationApprovedAt: Instant?,
val cancelReason: String,
val canceledBy: Long,
)

View File

@ -1,13 +1,13 @@
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.RegionException
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
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 {}
@ -17,56 +17,56 @@ class RegionService(
) {
@Transactional(readOnly = true)
fun readAllSido(): SidoListResponse {
log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" }
log.info { "[readAllSido] 모든 시/도 조회 시작" }
val result: List<Pair<String, String>> = regionRepository.readAllSido()
if (result.isEmpty()) {
log.warn { "[RegionService.readAllSido] 시/도 조회 실패" }
log.warn { "[readAllSido] 시/도 조회 실패" }
throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND)
}
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)
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)
if (result.isEmpty()) {
log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
log.warn { "[findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" }
throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND)
}
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)
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 {
log.info { "[RegionService.findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
RegionCodeResponse(it)
} ?: run {
log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
log.warn { "[findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findRegionInfo(regionCode: String): RegionInfoResponse {
log.info { "[RegionService.findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
log.info { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
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)
} ?: run {
log.warn { "[RegionService.findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
log.warn { "[findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" }
throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND)
}
}

View File

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

View File

@ -1,14 +1,14 @@
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.ReservationException
import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse
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 {}

View File

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

View File

@ -1,9 +1,9 @@
package com.sangdol.roomescape.reservation.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.web.*
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse

View File

@ -1,7 +1,7 @@
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.web.HttpStatus
enum class ReservationErrorCode(
override val httpStatus: HttpStatus,

View File

@ -5,7 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.Instant
@Entity
@Table(name = "canceled_reservation")
@ -15,7 +15,7 @@ class CanceledReservationEntity(
val reservationId: Long,
val canceledBy: Long,
val cancelReason: String,
val canceledAt: LocalDateTime,
val canceledAt: Instant,
@Enumerated(value = EnumType.STRING)
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.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import java.time.Instant
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
@Modifying
@Query("""
@Query(
"""
UPDATE
reservation r
JOIN
@ -23,6 +24,7 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
s.hold_expired_at = NULL
WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
""", nativeQuery = true)
fun expirePendingReservations(@Param("now") now: LocalDateTime): Int
""", nativeQuery = true
)
fun expirePendingReservations(@Param("now") now: Instant): Int
}

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.reservation.web
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.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.business.ReservationService
import com.sangdol.roomescape.reservation.docs.ReservationAPI
import jakarta.validation.Valid

View File

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

View File

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

View File

@ -1,5 +1,6 @@
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.ScheduleException
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
@ -13,6 +14,7 @@ import org.springframework.stereotype.Component
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.temporal.ChronoUnit
private val log: KLogger = KotlinLogging.logger {}
@ -56,9 +58,10 @@ class ScheduleValidator(
}
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 {
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
}

View File

@ -1,12 +1,12 @@
package com.sangdol.roomescape.schedule.docs
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.schedule.web.*
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse

View File

@ -1,7 +1,7 @@
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.web.HttpStatus
enum class ScheduleErrorCode(
override val httpStatus: HttpStatus,

View File

@ -3,8 +3,8 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence
import com.sangdol.common.persistence.AuditingBaseEntity
import jakarta.persistence.*
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
@Entity
@ -20,7 +20,7 @@ class ScheduleEntity(
@Enumerated(value = EnumType.STRING)
var status: ScheduleStatus,
var holdExpiredAt: LocalDateTime? = null
var holdExpiredAt: Instant? = null
) : AuditingBaseEntity(id) {
fun modifyIfNotNull(
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.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("""
@Query(
"""
SELECT
s
FROM
ScheduleEntity s
WHERE
s._id = :id
""")
"""
)
fun findByIdForUpdate(id: Long): ScheduleEntity?
@Query(
@ -108,7 +110,7 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.status = :changeStatus,
s.holdExpiredAt = CASE
WHEN :changeStatus = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
THEN CURRENT_TIMESTAMP + 5 MINUTE
THEN :expiredAt
ELSE NULL
END
WHERE
@ -117,7 +119,12 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
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
@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
import com.sangdol.roomescape.common.types.AuditingInfo
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.docs.AdminScheduleAPI
import jakarta.validation.Valid

View File

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

View File

@ -1,13 +1,13 @@
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.StoreException
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.store.web.StoreRegisterRequest
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 {}

View File

@ -1,8 +1,8 @@
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.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class StoreException(
override val errorCode: StoreErrorCode,

View File

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

View File

@ -1,13 +1,13 @@
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.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.web.ThemeCreateRequest
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 {}

View File

@ -1,7 +1,7 @@
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.web.HttpStatus
enum class ThemeErrorCode(
override val httpStatus: HttpStatus,

View File

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

View File

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

View File

@ -1,8 +1,8 @@
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.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class UserException(
override val errorCode: UserErrorCode,

View File

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

View File

@ -1,11 +1,11 @@
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.NotEmpty
import jakarta.validation.constraints.Pattern
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

View File

@ -7,7 +7,7 @@ spring:
ddl-auto: validate
datasource:
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
username: root
password: init
@ -42,4 +42,14 @@ jdbc:
management:
tracing:
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
spring:
application:
name: roomescape-backend
profiles:
active: ${ACTIVE_PROFILE:local}
jpa:
@ -21,7 +23,7 @@ management:
show-details: always
payment:
api-base-url: https://api.tosspayments.com
api-base-url: ${PAYMENT_SERVER_ENDPOINT:/https://api.tosspayments.com}
springdoc:
swagger-ui:

View File

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

View File

@ -2,35 +2,36 @@ package com.sangdol.data
import com.sangdol.common.persistence.IDGenerator
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.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.payment.infrastructure.common.*
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
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.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.randomPhoneNumber
import com.sangdol.roomescape.supports.randomString
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.infrastructure.persistence.UserEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus
import com.sangdol.roomescape.user.web.UserContactResponse
import io.kotest.core.test.TestCaseOrder
import jakarta.persistence.EntityManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ActiveProfiles
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
@ActiveProfiles("test", "data")
abstract class AbstractDataInitializer(
@ -151,22 +152,22 @@ class DefaultDataInitializer : AbstractDataInitializer() {
AdminPermissionLevel.READ_SUMMARY to 3
)
val storeIds: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
val stores: List<StoreEntity> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT s.id FROM StoreEntity s",
Long::class.java
"SELECT s FROM StoreEntity s",
StoreEntity::class.java
).resultList
}!!.map { it as Long }
}!!
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
storeIds.forEach { storeId ->
stores.forEach { store ->
// StoreManager 1명 생성
val storeManager = AdminFixture.create(
account = "$storeId",
account = store.name,
name = randomKoreanName(),
phone = randomPhoneNumber(),
type = AdminType.STORE,
storeId = storeId,
storeId = store.id,
permissionLevel = AdminPermissionLevel.FULL_ACCESS
).apply {
this.createdBy = superHQAdmin.id
@ -178,11 +179,11 @@ class DefaultDataInitializer : AbstractDataInitializer() {
storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) ->
repeat(count) { index ->
AdminFixture.create(
account = randomString(),
account = "${store.name}-${permissionLevel.ordinal}${index}",
name = randomKoreanName(),
phone = randomPhoneNumber(),
type = AdminType.STORE,
storeId = storeId,
storeId = store.id,
permissionLevel = permissionLevel
).apply {
this.createdBy = storeManager.id
@ -217,7 +218,7 @@ class DefaultDataInitializer : AbstractDataInitializer() {
val batchArgs = mutableListOf<Array<Any>>()
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 randomThemeName =
(1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } }
@ -323,7 +324,7 @@ class UserDataInitializer : AbstractDataInitializer() {
} while (true)
user.phone = newPhone
user.updatedAt = LocalDateTime.now()
user.updatedAt = Instant.now()
entityManager.merge(user)
}
}
@ -417,25 +418,49 @@ class UserDataInitializer : AbstractDataInitializer() {
class ScheduleDataInitializer : AbstractDataInitializer() {
init {
context("일정 초기 데이터 생성") {
test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") {
test("테마 생성일 기준으로 다음 3일차, 매일 최대 10개의 일정을 모든 매장에 생성") {
val stores: List<Pair<Long, Long>> = getStoreWithManagers()
val themes: List<Triple<Long, Short, LocalDateTime>> = getThemes()
val maxAvailableMinutes = themes.maxOf { it.second.toInt() }
val scheduleCountPerDay = 5
val themes: List<ThemeEntity> = getThemes()
val maxScheduleCountPerDay = 10
val startTime = LocalTime.of(10, 0)
var lastTime = startTime
val times = mutableListOf<LocalTime>()
repeat(scheduleCountPerDay) {
times.add(lastTime)
lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L)
val themeWithTimes: Map<ThemeEntity, List<LocalTime>> = themes.associateWith { theme ->
val times = mutableListOf<LocalTime>()
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 {
themes.forEach { theme ->
stores.map { store ->
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(
theme: Triple<Long, Short, LocalDateTime>,
stores: List<Pair<Long, Long>>,
times: List<LocalTime>
store: Pair<Long, Long>,
themeWithTimes: Map<ThemeEntity, List<LocalTime>>
) {
val sql = """
INSERT INTO schedule (
@ -457,24 +481,33 @@ class ScheduleDataInitializer : AbstractDataInitializer() {
val batchArgs = mutableListOf<Array<Any>>()
val now = LocalDateTime.now()
stores.forEach { (storeId, adminId) ->
(1..3).forEach { dayOffset ->
val date = theme.third.toLocalDate().plusDays(dayOffset.toLong())
val status = ScheduleStatus.RESERVED.name
themeWithTimes.forEach { (theme, times) ->
val themeCreatedAt = theme.createdAt
(1..3).forEach {
val themeCreatedDateTime = themeCreatedAt.atZone(ZoneId.systemDefault())
val themeCreatedDate = themeCreatedDateTime.toLocalDate().plusDays(it.toLong())
val themeCreatedTime = themeCreatedDateTime.toLocalTime()
times.forEach { time ->
val scheduledAt = LocalDateTime.of(date, time)
val status =
if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name
val storeId = store.first
val storeAdminId = store.second
batchArgs.add(
arrayOf(
idGenerator.create(), storeId, theme.first, date, time,
status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now)
idGenerator.create(),
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() }
}
}
@ -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) {
entityManager.createQuery(
"SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t",
List::class.java
)
.resultList
}!!.map {
val array = it as List<*>
Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime)
}
"SELECT t FROM ThemeEntity t",
ThemeEntity::class.java
).resultList
}!!
}
}
@ -528,10 +557,10 @@ class ReservationDataInitializer : AbstractDataInitializer() {
init {
context("예약 초기 데이터 생성") {
test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") {
val chunkSize = 10_000
val chunkSize = 500
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
).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize)
@ -587,10 +616,6 @@ class ReservationDataInitializer : AbstractDataInitializer() {
user.id,
)
)
if (batchArgs.size >= 1_000) {
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() {
companion object {
val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime())
val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime())
val requestedAtCache: Timestamp = Timestamp.valueOf(KoreaDateTime.nowWithOffset().toLocalDateTime())
val approvedAtCache: Timestamp =
Timestamp.valueOf(KoreaDateTime.nowWithOffset().plusSeconds(5).toLocalDateTime())
val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD)
val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK)
@ -671,7 +697,7 @@ class PaymentDataInitializer : AbstractDataInitializer() {
}
coroutineScope {
allReservations.chunked(10_000).forEach { reservations ->
allReservations.chunked(500).forEach { reservations ->
launch(Dispatchers.IO) {
processPaymentAndDefaultDetail(reservations)
}
@ -681,12 +707,12 @@ class PaymentDataInitializer : AbstractDataInitializer() {
test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") {
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
).resultList
coroutineScope {
allPayments.chunked(10_000).forEach { payments ->
allPayments.chunked(500).forEach { payments ->
launch(Dispatchers.IO) {
processPaymentDetail(payments)
}
@ -731,9 +757,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
approvedAtCache,
)
)
if (paymentBatchArgs.size >= 1_000) {
executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() }
}
val suppliedAmount: Int = (totalPrice * 0.9).toInt()
val vat: Int = (totalPrice - suppliedAmount)
@ -746,10 +769,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
vat
)
)
if (detailBatchArgs.size >= 1_000) {
executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() }
}
}
if (paymentBatchArgs.isNotEmpty()) {
@ -780,9 +799,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
settlementStatus
)
)
if (transferBatchArgs.size >= 1_000) {
executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() }
}
}
PaymentMethod.EASY_PAY -> {
@ -803,10 +819,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
)
)
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
} else {
easypayPrepaidBatchArgs.add(
arrayOf(
@ -816,10 +828,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
randomDiscountAmount,
)
)
if (easypayPrepaidBatchArgs.size >= 1_000) {
executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() }
}
}
}
@ -839,10 +847,6 @@ class PaymentDataInitializer : AbstractDataInitializer() {
0,
)
)
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
}
else -> return@forEach
@ -855,7 +859,10 @@ class PaymentDataInitializer : AbstractDataInitializer() {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
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()
storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}"
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))
usedStoreName.add(storeName)
@ -158,13 +163,17 @@ class StoreDataInitializer {
}
File("$BASE_DIR/store_data.txt").also {
if (it.exists()) { it.delete() }
if (it.exists()) {
it.delete()
}
}.writeText(
storeDataRows.joinToString("\n")
)
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 ")
.append(storeSqlRows.joinToString(",\n"))
@ -195,8 +204,7 @@ private fun randomLocalDateTime(): String {
return LocalDateTime.of(year, month, day, hour, minute, second)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"))
}
private fun generateBusinessRegNum(): String {

View File

@ -1,13 +1,6 @@
package com.sangdol.roomescape.auth
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.roomescape.admin.exception.AdminErrorCode
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.user.exception.UserErrorCode
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(
@SpykBean private val jwtUtils: JwtUtils,

View File

@ -1,8 +1,6 @@
package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean
import io.mockk.clearMocks
import io.mockk.every
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
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.UserFixture
import com.sangdol.roomescape.supports.runTest
import io.mockk.clearMocks
import io.mockk.every
class FailOnSaveLoginHistoryTest(
@MockkBean private val loginHistoryRepository: LoginHistoryRepository

View File

@ -1,10 +1,6 @@
package com.sangdol.roomescape.payment
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.roomescape.auth.exception.AuthErrorCode
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.PaymentCreateResponse
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(
@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.PaymentException
import com.sangdol.roomescape.payment.infrastructure.common.BankCode
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 com.sangdol.roomescape.payment.infrastructure.common.*
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

View File

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

View File

@ -1,6 +1,12 @@
package com.sangdol.roomescape.payment
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.throwables.shouldThrow
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.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseActions
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.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)
@MockkBean(JpaMetamodelMappingContext::class)

View File

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

View File

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

View File

@ -13,7 +13,8 @@ import io.kotest.assertions.assertSoftly
import io.kotest.matchers.shouldBe
import org.springframework.data.repository.findByIdOrNull
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
@ -31,7 +32,7 @@ class IncompletedReservationSchedulerTest(
test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") {
val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply {
this.status = ScheduleStatus.HOLD
this.holdExpiredAt = LocalDateTime.now().minusSeconds(1)
this.holdExpiredAt = Instant.now().minusSeconds(1)
}.also {
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}")
}
val now = LocalDateTime.now()
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
incompletedReservationScheduler.processExpiredReservation()
}
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldBe ReservationStatus.EXPIRED
this.updatedAt.hour shouldBe now.hour
this.updatedAt.minute shouldBe now.minute
this.updatedAt.truncatedTo(ChronoUnit.MINUTES) shouldBe Instant.now().truncatedTo(ChronoUnit.MINUTES)
}
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {

View File

@ -1,13 +1,8 @@
package com.sangdol.roomescape.reservation
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 com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.web.HttpStatus
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.CardIssuerCode
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.theme.infrastructure.persistence.ThemeEntity
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.LocalTime
@ -574,7 +574,10 @@ class ReservationApiTest(
expect = {
statusCode(HttpStatus.OK.value())
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")
}
).also {

View File

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

View File

@ -1,10 +1,13 @@
package com.sangdol.roomescape.schedule
import com.sangdol.roomescape.common.types.Auditor
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.AdminType
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.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
@ -54,7 +57,8 @@ class AdminScheduleApiTest(
lateinit var token: String
beforeTest {
val today = LocalDate.now()
val now = KoreaDateTime.now()
val today = now.toLocalDate()
store = dummyInitializer.createStore()
val admin = AdminFixture.createStoreAdmin(storeId = store.id)
token = testAuthUtil.adminLogin(admin).second
@ -66,21 +70,21 @@ class AdminScheduleApiTest(
storeId = store.id,
request = ScheduleFixture.createRequest.copy(
date = today,
time = LocalTime.now().plusHours(2)
time = now.toLocalTime().plusHours(2)
)
),
dummyInitializer.createSchedule(
storeId = store.id,
request = ScheduleFixture.createRequest.copy(
date = today,
time = LocalTime.now().plusHours(1)
time = now.toLocalTime().plusHours(1)
)
),
dummyInitializer.createSchedule(
storeId = store.id,
request = ScheduleFixture.createRequest.copy(
date = today.plusDays(1),
time = LocalTime.of(11, 0)
time = LocalTime.of(10, 0)
)
)
)
@ -95,7 +99,10 @@ class AdminScheduleApiTest(
},
expect = {
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(
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
propsNameIfList = "schedules"
@ -386,8 +393,9 @@ class AdminScheduleApiTest(
test("과거 시간을 선택하면 실패한다.") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
val date = LocalDate.now()
val time = LocalTime.now().minusMinutes(1)
val now = KoreaDateTime.now()
val date = now.toLocalDate()
val time = now.toLocalTime().minusMinutes(1)
val theme = dummyInitializer.createTheme()
val request = ScheduleFixture.createRequest.copy(date = date, time = time, themeId = theme.id)
@ -490,29 +498,50 @@ class AdminScheduleApiTest(
}
context("정상 응답") {
test("시간만 변경한다.") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
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())
context("시간만 변경한다.") {
test("성공") {
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
val schedule = initialize("수정을 위한 일정 생성") {
dummyInitializer.createSchedule()
}
).also {
val updated = scheduleRepository.findByIdOrNull(schedule.id)!!
updated.time shouldBe updateTime
updated.status shouldBe schedule.status
updated.updatedAt shouldNotBe schedule.updatedAt
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 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
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.AdminType
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.http.HttpMethod
import java.time.LocalDate
import java.time.LocalTime
class ScheduleApiTest(
private val scheduleRepository: ScheduleRepository
) : FunSpecSpringbootTest() {
init {
context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") {
/**
* @throws 23 57 ~ 59분에 실행하면 실패함.
*/
test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") {
val size = 3
val date = LocalDate.now()
val date = KoreaDate.today()
val store = dummyInitializer.createStore()
initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") {
@ -31,7 +35,7 @@ class ScheduleApiTest(
storeId = store.id,
request = ScheduleFixture.createRequest.copy(
date = date,
time = LocalTime.now().plusMinutes(i.toLong())
time = KoreaTime.now().plusMinutes(i.toLong())
)
)
}
@ -40,24 +44,28 @@ class ScheduleApiTest(
storeId = store.id,
request = ScheduleFixture.createRequest.copy(
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(
on = {
get("/stores/${store.id}/schedules?date=${date}")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.schedules.size()", equalTo(expectedSize))
body("data.schedules.size()", equalTo(size))
assertProperties(
props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"),
props = setOf(
"id",
"startFrom",
"endAt",
"themeId",
"themeName",
"themeDifficulty",
"status"
),
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.runTest
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.shouldBe
import kotlinx.coroutines.Dispatchers

View File

@ -1,10 +1,5 @@
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.roomescape.admin.infrastructure.persistence.AdminEntity
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.web.StoreUpdateRequest
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(
private val storeRepository: StoreRepository,

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