From 8a56dc784108e7cad9e516d67558362824dc099e Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 5 Aug 2025 17:30:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20theme=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20Finder,=20Writer,=20Validator=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/web/ReservationResponse.kt | 4 +- .../roomescape/theme/business/ThemeService.kt | 79 +++++++------------ .../kotlin/roomescape/theme/docs/ThemeAPI.kt | 3 +- .../theme/exception/ThemeErrorCode.kt | 3 +- .../roomescape/theme/implement/ThemeFinder.kt | 19 +++++ .../theme/implement/ThemeValidator.kt | 43 ++++++++++ .../roomescape/theme/implement/ThemeWriter.kt | 41 ++++++++++ .../persistence/ThemeRepository.kt | 20 +++-- .../roomescape/theme/web/ThemeController.kt | 4 +- .../kotlin/roomescape/theme/web/ThemeDTO.kt | 21 ++++- 10 files changed, 165 insertions(+), 72 deletions(-) create mode 100644 src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt create mode 100644 src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt index 7b733dac..adbbfe6c 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt @@ -7,7 +7,7 @@ import roomescape.member.web.toRetrieveResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.web.ThemeRetrieveResponse -import roomescape.theme.web.toResponse +import roomescape.theme.web.toRetrieveResponse import roomescape.time.web.TimeCreateResponse import roomescape.time.web.toCreateResponse import java.time.LocalDate @@ -53,7 +53,7 @@ fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = Reserv date = this.date, member = this.member.toRetrieveResponse(), time = this.time.toCreateResponse(), - theme = this.theme.toResponse(), + theme = this.theme.toRetrieveResponse(), status = this.status ) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 665fe7a2..906ec35b 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -1,92 +1,67 @@ package roomescape.theme.business -import com.github.f4b6a3.tsid.TsidFactory 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.theme.exception.ThemeErrorCode -import roomescape.theme.exception.ThemeException +import roomescape.theme.implement.ThemeFinder +import roomescape.theme.implement.ThemeWriter import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeCreateRequest -import roomescape.theme.web.ThemeRetrieveListResponse -import roomescape.theme.web.ThemeRetrieveResponse -import roomescape.theme.web.toResponse +import roomescape.theme.web.* import java.time.LocalDate private val log = KotlinLogging.logger {} @Service class ThemeService( - private val tsidFactory: TsidFactory, - private val themeRepository: ThemeRepository, + private val themeFinder: ThemeFinder, + private val themeWriter: ThemeWriter, ) { @Transactional(readOnly = true) fun findById(id: Long): ThemeEntity { - log.debug { "[ThemeService.findById] 테마 조회 시작: themeId=$id" } + log.info { "[ThemeService.findById] 시작: themeId=$id" } - return themeRepository.findByIdOrNull(id) - ?.also { log.info { "[ThemeService.findById] 테마 조회 완료: themeId=$id" } } - ?: run { - log.warn { "[ThemeService.findById] 테마 조회 실패: themeId=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } + return themeFinder.findById(id) + .also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } } } @Transactional(readOnly = true) fun findThemes(): ThemeRetrieveListResponse { - log.debug { "[ThemeService.findThemes] 모든 테마 조회 시작" } + log.info { "[ThemeService.findThemes] 시작" } - return themeRepository.findAll() - .also { log.info { "[ThemeService.findThemes] ${it.size}개의 테마 조회 완료" } } - .toResponse() + return themeFinder.findAll() + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } } } @Transactional(readOnly = true) fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { - log.debug { "[ThemeService.findMostReservedThemes] 인기 테마 조회 시작: count=$count" } + log.info { "[ThemeService.findMostReservedThemes] 시작: count=$count" } val today = LocalDate.now() - val startDate = today.minusDays(7) - val endDate = today.minusDays(1) + val startFrom = today.minusDays(7) + val endAt = today.minusDays(1) - return themeRepository.findPopularThemes(startDate, endDate, count) - .also { log.info { "[ThemeService.findMostReservedThemes] ${it.size} 개의 인기 테마 조회 완료" } } - .toResponse() + return themeFinder.findMostReservedThemes(count, startFrom, endAt) + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } } } @Transactional - fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse { - log.debug { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } + fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { + log.info { "[ThemeService.createTheme] 시작: name=${request.name}" } - if (themeRepository.existsByName(request.name)) { - log.info { "[ThemeService.createTheme] 테마 생성 실패(이름 중복): name=${request.name}" } - throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) - } - - val theme = ThemeEntity( - _id = tsidFactory.next(), - name = request.name, - description = request.description, - thumbnail = request.thumbnail - ) - return themeRepository.save(theme) - .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } } - .toResponse() + return themeWriter.create(request.name, request.description, request.thumbnail) + .toCreateResponse() + .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } } } @Transactional fun deleteTheme(id: Long) { - log.debug { "[ThemeService.deleteTheme] 테마 삭제 시작: themeId=$id" } + log.info { "[ThemeService.deleteTheme] 시작: themeId=$id" } - if (themeRepository.isReservedTheme(id)) { - log.info { "[ThemeService.deleteTheme] 테마 삭제 실패(예약이 있는 테마): themeId=$id" } - throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) - } + val theme: ThemeEntity = themeFinder.findById(id) - themeRepository.deleteById(id) - .also { log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: themeId=$id" } } + themeWriter.delete(theme) + .also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } } } } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt index 4f3f50d3..0a9db7dc 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt @@ -14,6 +14,7 @@ import roomescape.auth.web.support.Admin import roomescape.auth.web.support.LoginRequired import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.ThemeCreateRequest +import roomescape.theme.web.ThemeCreateResponse import roomescape.theme.web.ThemeRetrieveListResponse import roomescape.theme.web.ThemeRetrieveResponse @@ -38,7 +39,7 @@ interface ThemeAPI { ) fun createTheme( @Valid @RequestBody request: ThemeCreateRequest, - ): ResponseEntity> + ): ResponseEntity> @Admin @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) diff --git a/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt index 2f05632c..9e8450c1 100644 --- a/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt +++ b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt @@ -10,5 +10,6 @@ enum class ThemeErrorCode( ) : ErrorCode { THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."), THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."), - THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요.") + THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요."), + INVALID_REQUEST_VALUE(HttpStatus.BAD_REQUEST, "TH004", "입력 값이 잘못되었어요."), } diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt b/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt index a8af0b4f..42a86062 100644 --- a/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt +++ b/src/main/kotlin/roomescape/theme/implement/ThemeFinder.kt @@ -8,6 +8,7 @@ import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository +import java.time.LocalDate private val log: KLogger = KotlinLogging.logger {} @@ -15,6 +16,13 @@ private val log: KLogger = KotlinLogging.logger {} class ThemeFinder( private val themeRepository: ThemeRepository ) { + fun findAll(): List { + log.debug { "[ThemeFinder.findAll] 시작" } + + return themeRepository.findAll() + .also { log.debug { "[TimeFinder.findAll] ${it.size}개 테마 조회 완료" } } + } + fun findById(id: Long): ThemeEntity { log.debug { "[ThemeFinder.findById] 조회 시작: memberId=$id" } @@ -25,4 +33,15 @@ class ThemeFinder( throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) } } + + fun findMostReservedThemes( + count: Int, + startFrom: LocalDate, + endAt: LocalDate + ): List { + log.debug { "[ThemeFinder.findMostReservedThemes] 시작. count=$count, startFrom=$startFrom, endAt=$endAt" } + + return themeRepository.findPopularThemes(startFrom, endAt, count) + .also { log.debug { "[ThemeFinder.findMostReservedThemes] ${it.size} / ${count}개 테마 조회 완료" } } + } } diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt b/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt new file mode 100644 index 00000000..6887d894 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/implement/ThemeValidator.kt @@ -0,0 +1,43 @@ +package roomescape.theme.implement + +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.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ThemeValidator( + private val themeRepository: ThemeRepository +) { + fun validateNameAlreadyExists(name: String) { + log.debug { "[ThemeValidator.validateNameAlreadyExists] 시작: name=$name" } + + if (themeRepository.existsByName(name)) { + log.info { "[ThemeService.createTheme] 이름 중복: name=${name}" } + throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) + } + + log.debug { "[ThemeValidator.validateNameAlreadyExists] 완료: name=$name" } + } + + fun validateIsReserved(theme: ThemeEntity) { + val themeId: Long = theme.id ?: run { + log.warn { "[ThemeValidator.validateIsReserved] ID를 찾을 수 없음: name:${theme.name}" } + throw ThemeException(ThemeErrorCode.INVALID_REQUEST_VALUE) + } + + log.debug { "[ThemeValidator.validateIsReserved] 시작: themeId=${themeId}" } + + if (themeRepository.isReservedTheme(themeId)) { + log.info { "[ThemeService.deleteTheme] 예약이 있는 테마: themeId=$themeId" } + throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) + } + + log.debug { "[ThemeValidator.validateIsReserved] 완료: themeId=$themeId" } + } +} diff --git a/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt b/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt new file mode 100644 index 00000000..3f07b335 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/implement/ThemeWriter.kt @@ -0,0 +1,41 @@ +package roomescape.theme.implement + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.common.config.next +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ThemeWriter( + private val themeValidator: ThemeValidator, + private val themeRepository: ThemeRepository, + private val tsidFactory: TsidFactory +) { + fun create(name: String, description: String, thumbnail: String): ThemeEntity { + log.debug { "[ThemeWriter.create] 시작: name=$name" } + themeValidator.validateNameAlreadyExists(name) + + val theme = ThemeEntity( + _id = tsidFactory.next(), + name = name, + description = description, + thumbnail = thumbnail + ) + + return themeRepository.save(theme) + .also { log.debug { "[ThemeWriter.create] 완료: name=$name, id=${it.id}" } } + } + + fun delete(theme: ThemeEntity) { + log.debug { "[ThemeWriter.delete] 시작: id=${theme.id}" } + themeValidator.validateIsReserved(theme) + + themeRepository.delete(theme) + .also { log.debug { "[ThemeWriter.delete] 완료: id=${theme.id}, name=${theme.name}" } } + } +} diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index fbb83c9d..c7129a93 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -6,27 +6,25 @@ import java.time.LocalDate interface ThemeRepository : JpaRepository { - @Query( - value = """ + @Query(value = """ SELECT t FROM ThemeEntity t - RIGHT JOIN ReservationEntity r ON t.id = r.theme.id - WHERE r.date BETWEEN :startDate AND :endDate - GROUP BY r.theme.id - ORDER BY COUNT(r.theme.id) DESC, t.id ASC - LIMIT :limit + RIGHT JOIN ReservationEntity r ON t._id = r.theme._id + WHERE r.date BETWEEN :startFrom AND :endAt + GROUP BY r.theme._id + ORDER BY COUNT(r.theme._id) DESC, t._id ASC + LIMIT :count """ ) - fun findPopularThemes(startDate: LocalDate, endDate: LocalDate, limit: Int): List + fun findPopularThemes(startFrom: LocalDate, endAt: LocalDate, count: Int): List fun existsByName(name: String): Boolean - @Query( - value = """ + @Query(value = """ SELECT EXISTS( SELECT 1 FROM ReservationEntity r - WHERE r.theme.id = :id + WHERE r.theme._id = :id ) """ ) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index 6931b37c..3b12fb3c 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -33,8 +33,8 @@ class ThemeController( @PostMapping("/themes") override fun createTheme( @RequestBody @Valid request: ThemeCreateRequest - ): ResponseEntity> { - val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request) + ): ResponseEntity> { + val themeResponse: ThemeCreateResponse = themeService.createTheme(request) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) .body(CommonApiResponse(themeResponse)) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt index 4db87e77..32b14485 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt @@ -21,6 +21,21 @@ data class ThemeCreateRequest( val thumbnail: String ) +data class ThemeCreateResponse( + val id: Long, + val name: String, + val description: String, + @Schema(description = "썸네일 이미지 주소(URL).") + val thumbnail: String +) + +fun ThemeEntity.toCreateResponse(): ThemeCreateResponse = ThemeCreateResponse( + id = this.id!!, + name = this.name, + description = this.description, + thumbnail = this.thumbnail +) + data class ThemeRetrieveResponse( val id: Long, val name: String, @@ -29,7 +44,7 @@ data class ThemeRetrieveResponse( val thumbnail: String ) -fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( +fun ThemeEntity.toRetrieveResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( id = this.id!!, name = this.name, description = this.description, @@ -40,6 +55,6 @@ data class ThemeRetrieveListResponse( val themes: List ) -fun List.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( - themes = this.map { it.toResponse() } +fun List.toRetrieveListResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( + themes = this.map { it.toRetrieveResponse() } )