[#52] 만료 예약 / 일정 스케쥴링 작업 추가 및 동시성 처리를 위한 일부 코드 수정 #53

Merged
pricelees merged 18 commits from refactor/#52 into main 2025-10-04 08:40:37 +00:00
5 changed files with 128 additions and 40 deletions
Showing only changes of commit 08b9920ee3 - Show all commits

View File

@ -2,15 +2,14 @@ package com.sangdol.roomescape.schedule.business
import ScheduleException
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.common.types.Auditor
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.*
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
@ -18,7 +17,6 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@ -67,14 +65,15 @@ class ScheduleService(
// ========================================
@Transactional
fun holdSchedule(id: Long) {
val schedule: ScheduleEntity = findOrThrow(id)
log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" }
val result: Int = scheduleRepository.holdAvailableScheduleById(id)
if (schedule.status == ScheduleStatus.AVAILABLE) {
schedule.hold()
return
if (result == 0) {
log.info { "[ScheduleService.holdSchedule] Holding된 일정 없음: id=${id}, count = " }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
log.info { "[ScheduleService.holdSchedule] 일정 Holding 완료: id=$id" }
}
// ========================================

View File

@ -1,14 +1,9 @@
package com.sangdol.roomescape.schedule.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.persistence.AuditingBaseEntity
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
@Entity
@ -24,36 +19,13 @@ class ScheduleEntity(
@Enumerated(value = EnumType.STRING)
var status: ScheduleStatus,
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
var updatedBy: Long = 0L
) : AuditingBaseEntity(id) {
fun modifyIfNotNull(
time: LocalTime?,
status: ScheduleStatus?
) {
time?.let { this.time = it }
status?.let { this.status = it }
updateLastModifiedBy()
}
fun hold() {
this.status = ScheduleStatus.HOLD
}
fun updateLastModifiedBy() {
MdcPrincipalIdUtil.extractAsLongOrNull()?.also { this.updatedBy = it }
}
}
@ -66,7 +38,7 @@ object ScheduleEntityFactory {
storeId = storeId,
themeId = themeId,
status = ScheduleStatus.AVAILABLE
).apply { this.updateLastModifiedBy() }
)
}
}

View File

@ -3,6 +3,7 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
import org.springframework.data.jpa.repository.Modifying
import java.time.LocalDate
import java.time.LocalTime
@ -82,4 +83,17 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
"""
)
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
@Modifying
@Query("""
UPDATE
ScheduleEntity s
SET
s.status = 'HOLD'
WHERE
s._id = :id
AND
s.status = 'AVAILABLE'
""")
fun holdAvailableScheduleById(id: Long): Int
}

View File

@ -130,7 +130,7 @@ class ScheduleApiTest(
context("일정이 ${ScheduleStatus.AVAILABLE}이 아니면 실패한다.") {
(ScheduleStatus.entries - ScheduleStatus.AVAILABLE).forEach {
test("${it}") {
test("$it") {
val schedule = dummyInitializer.createSchedule(status = it)
runExceptionTest(
@ -142,6 +142,15 @@ class ScheduleApiTest(
}
}
}
test("일정이 없으면 실패한다.") {
runExceptionTest(
token = testAuthUtil.defaultUserLogin().second,
method = HttpMethod.POST,
endpoint = "/schedules/${INVALID_PK}/hold",
expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE
)
}
}
}
}

View File

@ -0,0 +1,94 @@
package com.sangdol.roomescape.schedule
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.IDGenerator
import com.sangdol.roomescape.supports.initialize
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.springframework.data.repository.findByIdOrNull
import java.time.LocalDate
import java.time.LocalTime
class ScheduleServiceTest(
private val scheduleService: ScheduleService,
private val scheduleRepository: ScheduleRepository
) : FunSpecSpringbootTest() {
init {
afterTest {
MdcPrincipalIdUtil.clear()
}
test("관리자가 일정을 수정하면 updatedAt, updatedBy 컬럼이 변경된다. ") {
val createdScheduleId = createSchedule()
initialize("updatedBy 변경 확인을 위한 MDC 재설정") {
MdcPrincipalIdUtil.clear()
IDGenerator.create().also {
MdcPrincipalIdUtil.set(it.toString())
}
}
scheduleService.updateSchedule(
createdScheduleId,
ScheduleUpdateRequest(status = ScheduleStatus.RESERVED)
)
assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) {
this.shouldNotBeNull()
this.updatedAt shouldNotBe this.createdAt
this.updatedBy shouldNotBe this.createdBy
}
}
test("유저가 일정을 Holding 하는 경우에는 updatedAt, updatedBy 컬럼이 변경되지 않는다.") {
val createdScheduleId: Long = createSchedule()
initialize("updatedBy 변경 확인을 위한 MDC 재설정") {
MdcPrincipalIdUtil.clear()
IDGenerator.create().also {
MdcPrincipalIdUtil.set(it.toString())
}
}
scheduleService.holdSchedule(createdScheduleId)
assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) {
this.shouldNotBeNull()
this.updatedAt shouldBe this.createdAt
this.updatedBy shouldBe this.createdBy
}
}
}
fun createSchedule(): Long {
initialize("createdBy, updatedBy 설정을 위한 MDC 설정") {
IDGenerator.create().also {
MdcPrincipalIdUtil.set(it.toString())
}
}
val (storeId, themeId) = initialize("FK 제약조건 해소를 위한 매장, 테마 생성") {
val store = dummyInitializer.createStore()
val theme = dummyInitializer.createTheme()
store.id to theme.id
}
return scheduleService.createSchedule(
storeId = storeId,
request = ScheduleCreateRequest(
date = LocalDate.now().plusDays(1),
time = LocalTime.now(),
themeId = themeId
)
).id
}
}