From b4896e243e1c91860b583e1211e265bfc76664a2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 3 Sep 2025 10:46:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=EC=99=80=20=EA=B4=80=EB=A0=A8=EB=90=9C=20Service=20/?= =?UTF-8?q?=20Validator=20/=20Repository=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../theme/business/ThemeServiceV2.kt | 117 ++++++++++++++++++ .../theme/business/ThemeValidatorV2.kt | 114 +++++++++++++++++ .../persistence/v2/ThemeRepositoryV2.kt | 12 ++ 3 files changed, 243 insertions(+) create mode 100644 src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt create mode 100644 src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt create mode 100644 src/main/kotlin/roomescape/theme/infrastructure/persistence/v2/ThemeRepositoryV2.kt diff --git a/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt b/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt new file mode 100644 index 00000000..f7ecb609 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt @@ -0,0 +1,117 @@ +package roomescape.theme.business + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next +import roomescape.member.business.MemberService +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2 +import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 +import roomescape.theme.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class ThemeServiceV2( + private val themeRepository: ThemeRepositoryV2, + private val tsidFactory: TsidFactory, + private val memberService: MemberService, + private val themeValidator: ThemeValidatorV2 +) { + @Transactional(readOnly = true) + fun findThemesForReservation(): ThemeRetrieveListResponseV2 { + log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } + + return themeRepository.findOpenedThemes() + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } } + } + + @Transactional(readOnly = true) + fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse { + log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } + + return themeRepository.findAll() + .toAdminThemeSummaryListResponse() + .also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } } + } + + @Transactional(readOnly = true) + fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse { + log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작" } + + val theme = themeRepository.findByIdOrNull(id) + ?: run { + log.warn { "[ThemeService.findAdminThemeDetail] 테마 조회 실패. id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + + val createdBy = memberService.findById(theme.createdBy).name + val updatedBy = memberService.findById(theme.updatedBy).name + + return theme.toAdminThemeDetailResponse(createdBy, updatedBy) + .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료. id=$id, name=${theme.name}" } } + } + + @Transactional + fun createTheme(request: ThemeCreateRequestV2): ThemeCreateResponseV2 { + log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } + + themeValidator.validateCanCreate(request) + + val theme: ThemeEntityV2 = themeRepository.save( + request.toEntity(tsidFactory.next()) + ) + + return ThemeCreateResponseV2(theme.id).also { + log.info { "[ThemeService.createTheme] 테마 생성 완료. id=${theme.id}, name=${theme.name}" } + } + } + + @Transactional + fun deleteTheme(id: Long) { + log.info { "[ThemeService.deleteTheme] 테마 삭제 시작" } + + val theme = themeRepository.findByIdOrNull(id) + ?: run { + log.warn { "[ThemeService.deleteTheme] 테마 조회 실패. id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + + themeRepository.delete(theme).also { + log.info { "[ThemeService.deleteTheme] 테마 삭제 완료. id=$id, name=${theme.name}" } + } + } + + @Transactional + fun updateTheme(id: Long, request: ThemeUpdateRequest) { + log.info { "[ThemeService.updateTheme] 테마 수정 시작" } + + themeValidator.validateCanUpdate(request) + + val theme: ThemeEntityV2 = themeRepository.findByIdOrNull(id) + ?: run { + log.warn { "[ThemeService.updateTheme] 테마 조회 실패. id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + + theme.modifyIfNotNull( + request.name, + request.description, + request.thumbnailUrl, + request.difficulty, + request.price, + request.minParticipants, + request.maxParticipants, + request.availableMinutes, + request.expectedMinutesFrom, + request.expectedMinutesTo, + request.isOpen, + ) + } +} diff --git a/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt b/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt new file mode 100644 index 00000000..ee4eeee4 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt @@ -0,0 +1,114 @@ +package roomescape.theme.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException +import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 +import roomescape.theme.web.ThemeCreateRequestV2 +import roomescape.theme.web.ThemeUpdateRequest + +private val log: KLogger = KotlinLogging.logger {} + +const val MIN_PRICE = 0 +const val MIN_PARTICIPANTS = 1 +const val MIN_DURATION = 1 + +@Component +class ThemeValidatorV2( + private val themeRepository: ThemeRepositoryV2, +) { + fun validateCanUpdate(request: ThemeUpdateRequest) { + validateProperties( + request.price, + request.availableMinutes, + request.expectedMinutesFrom, + request.expectedMinutesTo, + request.minParticipants, + request.maxParticipants + ) + } + + fun validateCanCreate(request: ThemeCreateRequestV2) { + if (themeRepository.existsByName(request.name)) { + log.info { "[ThemeValidator.validateCanCreate] 이름 중복: name=${request.name}" } + throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) + } + + validateProperties( + request.price, + request.availableMinutes, + request.expectedMinutesFrom, + request.expectedMinutesTo, + request.minParticipants, + request.maxParticipants + ) + } + + private fun validateProperties( + price: Int?, + availableMinutes: Short?, + expectedMinutesFrom: Short?, + expectedMinutesTo: Short?, + minParticipants: Short?, + maxParticipants: Short?, + ) { + if (isNotNullAndBelowThan(price, MIN_PRICE)) { + log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달: price=${price}" } + throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM) + } + validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo) + validateParticipants(minParticipants, maxParticipants) + } + + private fun validateTimes( + availableMinutes: Short?, + expectedMinutesFrom: Short?, + expectedMinutesTo: Short? + ) { + if (isNotNullAndBelowThan(availableMinutes, MIN_DURATION) + || isNotNullAndBelowThan(expectedMinutesFrom, MIN_DURATION) + || isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION) + ) { + log.info { + "[ThemeValidator.validateTimes] 최소 시간 미달: availableMinutes=$availableMinutes" + + ", expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" + } + throw ThemeException(ThemeErrorCode.DURATION_BELOW_MINIMUM) + } + + if (expectedMinutesFrom.isNotNullAndGraterThan(expectedMinutesTo)) { + log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } + throw ThemeException(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME) + } + if (expectedMinutesTo.isNotNullAndGraterThan(availableMinutes)) { + log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } + throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME) + } + } + + private fun validateParticipants( + minParticipants: Short?, + maxParticipants: Short? + ) { + if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS) + || isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS) + ) { + log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } + throw ThemeException(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM) + } + if (minParticipants.isNotNullAndGraterThan(maxParticipants)) { + log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } + throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT) + } + } +} + +private fun isNotNullAndBelowThan(value: Number?, threshold: Int): Boolean { + return value != null && value.toInt() < threshold +} + +private fun Number?.isNotNullAndGraterThan(value: Number?): Boolean { + return this != null && value != null && (this.toInt() > value.toInt()) +} diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/v2/ThemeRepositoryV2.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/v2/ThemeRepositoryV2.kt new file mode 100644 index 00000000..64f12e7a --- /dev/null +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/v2/ThemeRepositoryV2.kt @@ -0,0 +1,12 @@ +package roomescape.theme.infrastructure.persistence.v2 + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface ThemeRepositoryV2: JpaRepository { + + @Query("SELECT t FROM ThemeEntityV2 t WHERE t.isOpen = true") + fun findOpenedThemes(): List + + fun existsByName(name: String): Boolean +} \ No newline at end of file