From 7fd278aa43325001634419c7d68ddb75c87ed84f Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:40:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=A5=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20CRUD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/store/business/StoreService.kt | 134 ++++++++++++++++++ .../kotlin/roomescape/store/docs/StoreAPI.kt | 64 +++++++++ .../store/exception/StoreException.kt | 19 +++ .../infrastructure/persistence/StoreEntity.kt | 25 ++-- .../persistence/StoreRepository.kt | 30 +++- .../store/web/AdminStoreController.kt | 52 +++++++ .../roomescape/store/web/AdminStoreDto.kt | 46 ++++++ .../roomescape/store/web/StoreController.kt | 35 +++++ .../kotlin/roomescape/store/web/StoreDTO.kt | 12 -- 9 files changed, 396 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/roomescape/store/business/StoreService.kt create mode 100644 src/main/kotlin/roomescape/store/docs/StoreAPI.kt create mode 100644 src/main/kotlin/roomescape/store/exception/StoreException.kt create mode 100644 src/main/kotlin/roomescape/store/web/AdminStoreController.kt create mode 100644 src/main/kotlin/roomescape/store/web/AdminStoreDto.kt create mode 100644 src/main/kotlin/roomescape/store/web/StoreController.kt diff --git a/src/main/kotlin/roomescape/store/business/StoreService.kt b/src/main/kotlin/roomescape/store/business/StoreService.kt new file mode 100644 index 00000000..bc534d6e --- /dev/null +++ b/src/main/kotlin/roomescape/store/business/StoreService.kt @@ -0,0 +1,134 @@ +package roomescape.store.business + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService +import roomescape.common.config.next +import roomescape.common.dto.AuditInfo +import roomescape.region.business.RegionService +import roomescape.store.exception.StoreErrorCode +import roomescape.store.exception.StoreException +import roomescape.store.infrastructure.persistence.StoreEntity +import roomescape.store.infrastructure.persistence.StoreRepository +import roomescape.store.infrastructure.persistence.StoreStatus +import roomescape.store.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class StoreService( + private val storeRepository: StoreRepository, + private val adminService: AdminService, + private val regionService: RegionService, + private val tsidFactory: TsidFactory, +) { + @Transactional(readOnly = true) + fun getDetail(id: Long): DetailStoreResponse { + log.info { "[StoreService.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}" } } + } + + @Transactional + fun register(request: StoreRegisterRequest): StoreRegisterResponse { + log.info { "[StoreService.register] 매장 등록 시작: name=${request.name}" } + + val store = StoreEntity( + id = tsidFactory.next(), + name = request.name, + address = request.address, + contact = request.contact, + businessRegNum = request.businessRegNum, + regionCode = request.regionCode, + status = StoreStatus.ACTIVE, + ).also { + storeRepository.save(it) + } + + return StoreRegisterResponse(store.id).also { + log.info { "[StoreService.register] 매장 등록 완료: id=${store.id}, name=${request.name}" } + } + } + + @Transactional + fun update(id: Long, request: StoreUpdateRequest) { + log.info { "[StoreService.update] 매장 수정 시작: id=${id}, request=${request}" } + + findOrThrow(id).apply { + this.modifyIfNotNull(request.name, request.address, request.contact) + }.also { + log.info { "[StoreService.update] 매장 수정 완료: id=${id}" } + } + } + + @Transactional + fun disableById(id: Long) { + log.info { "[StoreService.inactive] 매장 비활성화 시작: id=${id}" } + + findOrThrow(id).apply { + this.disable() + }.also { + log.info { "[StoreService.inactive] 매장 비활성화 완료: id=${id}" } + } + } + + @Transactional(readOnly = true) + fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse { + log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 시작" } + + val regionCode: String? = when { + sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED) + sidoCode != null -> "${sidoCode}${sigunguCode ?: ""}" + else -> null + } + + return storeRepository.findAllActiveStoresByRegion(regionCode).toSimpleListResponse() + .also { log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 완료: total=${it.stores.size}" } } + } + + @Transactional(readOnly = true) + fun findStoreInfo(id: Long): StoreInfoResponse { + log.info { "[StoreService.findStoreInfo] 매장 정보 조회 시작: id=${id}" } + + val store: StoreEntity = findOrThrow(id) + + return store.toInfoResponse() + .also { log.info { "[StoreService.findStoreInfo] 매장 정보 조회 완료: id=${id}" } } + } + + private fun getAuditInfo(store: StoreEntity): AuditInfo { + log.info { "[StoreService.getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" } + val createdBy = adminService.findOperatorOrUnknown(store.createdBy) + val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy) + + return AuditInfo( + createdAt = store.createdAt, + createdBy = createdBy, + updatedAt = store.updatedAt, + updatedBy = updatedBy + ).also { + log.info { "[StoreService.getAuditInfo] 감사 정보 조회 완료: storeId=${store.id}" } + } + } + + private fun findOrThrow(id: Long): StoreEntity { + log.info { "[StoreService.findOrThrow] 매장 조회 시작: id=${id}" } + + return storeRepository.findActiveStoreById(id) + ?.also { + log.info { "[StoreService.findOrThrow] 매장 조회 완료: id=${id}" } + } + ?: run { + log.warn { "[StoreService.findOrThrow] 매장 조회 실패: id=${id}" } + throw StoreException(StoreErrorCode.STORE_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/store/docs/StoreAPI.kt b/src/main/kotlin/roomescape/store/docs/StoreAPI.kt new file mode 100644 index 00000000..110ba588 --- /dev/null +++ b/src/main/kotlin/roomescape/store/docs/StoreAPI.kt @@ -0,0 +1,64 @@ +package roomescape.store.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.admin.infrastructure.persistence.Privilege +import roomescape.auth.web.support.AdminOnly +import roomescape.auth.web.support.Public +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.web.* + +interface AdminStoreAPI { + @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) + @Operation(summary = "특정 매장의 상세 정보 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun findStoreDetail( + @PathVariable id: Long + ): ResponseEntity> + + @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) + @Operation(summary = "매장 등록") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun registerStore( + @Valid @RequestBody request: StoreRegisterRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) + @Operation(summary = "매장 정보 수정") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun updateStore( + @PathVariable id: Long, + @Valid @RequestBody request: StoreUpdateRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE) + @Operation(summary = "매장 비활성화") + @ApiResponses(ApiResponse(responseCode = "204", useReturnTypeSchema = true)) + fun disableStore( + @PathVariable id: Long + ): ResponseEntity> +} + +interface PublicStoreAPI { + @Public + @Operation(summary = "모든 매장의 id / 이름 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun getStores( + @RequestParam(value = "sido", required = false) sidoCode: String?, + @RequestParam(value = "sigungu", required = false) sigunguCode: String? + ): ResponseEntity> + + @Public + @Operation(summary = "특정 매장의 정보 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun getStoreInfo( + @PathVariable id: Long + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/store/exception/StoreException.kt b/src/main/kotlin/roomescape/store/exception/StoreException.kt new file mode 100644 index 00000000..71fe9781 --- /dev/null +++ b/src/main/kotlin/roomescape/store/exception/StoreException.kt @@ -0,0 +1,19 @@ +package roomescape.store.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class StoreException( + override val errorCode: StoreErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class StoreErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "ST001", "매장을 찾을 수 없어요."), + SIDO_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "ST002", "시/도 정보를 찾을 수 없어요. 다시 시도해주세요.") +} diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt index faf79f46..d59ce007 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt @@ -1,11 +1,6 @@ package roomescape.store.infrastructure.persistence -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EntityListeners -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Table +import jakarta.persistence.* import org.springframework.data.jpa.domain.support.AuditingEntityListener import roomescape.common.entity.AuditingBaseEntity @@ -31,9 +26,23 @@ class StoreEntity( @Enumerated(value = EnumType.STRING) var status: StoreStatus -) : AuditingBaseEntity(id) +) : AuditingBaseEntity(id) { + fun modifyIfNotNull( + name: String?, + address: String?, + contact: String?, + ) { + name?.let { this.name = it } + address?.let { this.address = it } + contact?.let { this.contact = it } + } + + fun disable() { + this.status = StoreStatus.DISABLED + } +} enum class StoreStatus { ACTIVE, - INACTIVE + DISABLED } diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt index 0a55ccc6..84d6b3a3 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt @@ -1,5 +1,33 @@ package roomescape.store.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface StoreRepository : JpaRepository \ No newline at end of file +interface StoreRepository : JpaRepository { + + @Query( + """ + SELECT + s + FROM + StoreEntity s + WHERE + s._id = :id + AND s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE + """ + ) + fun findActiveStoreById(id: Long): StoreEntity? + + @Query( + """ + SELECT + s + FROM + StoreEntity s + WHERE + s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE + AND (:regionCode IS NULL OR s.regionCode LIKE ':regionCode%') + """ + ) + fun findAllActiveStoresByRegion(regionCode: String?): List +} diff --git a/src/main/kotlin/roomescape/store/web/AdminStoreController.kt b/src/main/kotlin/roomescape/store/web/AdminStoreController.kt new file mode 100644 index 00000000..4fd1d077 --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/AdminStoreController.kt @@ -0,0 +1,52 @@ +package roomescape.store.web + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.business.StoreService +import roomescape.store.docs.AdminStoreAPI + +@RestController +@RequestMapping("/admin/stores") +class AdminStoreController( + private val storeService: StoreService +) : AdminStoreAPI { + + @GetMapping("/{id}/detail") + override fun findStoreDetail( + @PathVariable id: Long + ): ResponseEntity> { + val response: DetailStoreResponse = storeService.getDetail(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping + override fun registerStore( + @Valid @RequestBody request: StoreRegisterRequest + ): ResponseEntity> { + val response: StoreRegisterResponse = storeService.register(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PatchMapping("/{id}") + override fun updateStore( + @PathVariable id: Long, + @Valid @RequestBody request: StoreUpdateRequest + ): ResponseEntity> { + storeService.update(id, request) + + return ResponseEntity.ok(CommonApiResponse()) + } + + @PostMapping("/{id}/disable") + override fun disableStore( + @PathVariable id: Long, + ): ResponseEntity> { + storeService.disableById(id) + + return ResponseEntity.ok(CommonApiResponse()) + } +} diff --git a/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt b/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt new file mode 100644 index 00000000..f3c7235e --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt @@ -0,0 +1,46 @@ +package roomescape.store.web + +import roomescape.common.dto.AuditInfo +import roomescape.region.web.RegionInfoResponse +import roomescape.store.infrastructure.persistence.StoreEntity + +data class StoreRegisterRequest( + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val regionCode: String +) + +data class StoreRegisterResponse( + val id: Long +) + +data class StoreUpdateRequest( + val name: String?, + val address: String?, + val contact: String?, +) + +data class DetailStoreResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val region: RegionInfoResponse, + val audit: AuditInfo +) + +fun StoreEntity.toDetailResponse( + region: RegionInfoResponse, + audit: AuditInfo +) = DetailStoreResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum, + region = region, + audit = audit, +) diff --git a/src/main/kotlin/roomescape/store/web/StoreController.kt b/src/main/kotlin/roomescape/store/web/StoreController.kt new file mode 100644 index 00000000..47a3937c --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/StoreController.kt @@ -0,0 +1,35 @@ +package roomescape.store.web + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.business.StoreService +import roomescape.store.docs.PublicStoreAPI + +@RestController +class StoreController( + private val storeService: StoreService +) : PublicStoreAPI { + + @GetMapping("/stores") + override fun getStores( + @RequestParam(value = "sido", required = false) sidoCode: String?, + @RequestParam(value = "sigungu", required = false) sigunguCode: String? + ): ResponseEntity> { + val response = storeService.getAllActiveStores(sidoCode, sigunguCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/stores/{id}") + override fun getStoreInfo( + @PathVariable id: Long + ): ResponseEntity> { + val response = storeService.findStoreInfo(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/store/web/StoreDTO.kt b/src/main/kotlin/roomescape/store/web/StoreDTO.kt index bf5211ed..aad2cd14 100644 --- a/src/main/kotlin/roomescape/store/web/StoreDTO.kt +++ b/src/main/kotlin/roomescape/store/web/StoreDTO.kt @@ -1,7 +1,5 @@ package roomescape.store.web -import roomescape.common.dto.AuditInfo -import roomescape.region.web.RegionInfoResponse import roomescape.store.infrastructure.persistence.StoreEntity data class SimpleStoreResponse( @@ -40,13 +38,3 @@ data class StoreInfoListResponse( fun List.toInfoListResponse() = StoreInfoListResponse( stores = this.map { it.toInfoResponse() } ) - -data class StoreDetailResponse( - val id: Long, - val name: String, - val address: String, - val contact: String, - val businessRegNum: String, - val region: RegionInfoResponse, - val audit: AuditInfo -)