[#44] 매장 기능 도입 #45

Merged
pricelees merged 116 commits from feat/#44 into main 2025-09-20 03:15:06 +00:00
9 changed files with 396 additions and 21 deletions
Showing only changes of commit 7fd278aa43 - Show all commits

View File

@ -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)
}
}
}

View File

@ -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<CommonApiResponse<DetailStoreResponse>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "매장 등록")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun registerStore(
@Valid @RequestBody request: StoreRegisterRequest
): ResponseEntity<CommonApiResponse<StoreRegisterResponse>>
@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<CommonApiResponse<Unit>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE)
@Operation(summary = "매장 비활성화")
@ApiResponses(ApiResponse(responseCode = "204", useReturnTypeSchema = true))
fun disableStore(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>>
}
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<CommonApiResponse<SimpleStoreListResponse>>
@Public
@Operation(summary = "특정 매장의 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getStoreInfo(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<StoreInfoResponse>>
}

View File

@ -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", "시/도 정보를 찾을 수 없어요. 다시 시도해주세요.")
}

View File

@ -1,11 +1,6 @@
package roomescape.store.infrastructure.persistence package roomescape.store.infrastructure.persistence
import jakarta.persistence.Column import jakarta.persistence.*
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import roomescape.common.entity.AuditingBaseEntity import roomescape.common.entity.AuditingBaseEntity
@ -31,9 +26,23 @@ class StoreEntity(
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var status: StoreStatus 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 { enum class StoreStatus {
ACTIVE, ACTIVE,
INACTIVE DISABLED
} }

View File

@ -1,5 +1,33 @@
package roomescape.store.infrastructure.persistence package roomescape.store.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface StoreRepository : JpaRepository<StoreEntity, Long> interface StoreRepository : JpaRepository<StoreEntity, Long> {
@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<StoreEntity>
}

View File

@ -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<CommonApiResponse<DetailStoreResponse>> {
val response: DetailStoreResponse = storeService.getDetail(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping
override fun registerStore(
@Valid @RequestBody request: StoreRegisterRequest
): ResponseEntity<CommonApiResponse<StoreRegisterResponse>> {
val response: StoreRegisterResponse = storeService.register(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PatchMapping("/{id}")
override fun updateStore(
@PathVariable id: Long,
@Valid @RequestBody request: StoreUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> {
storeService.update(id, request)
return ResponseEntity.ok(CommonApiResponse())
}
@PostMapping("/{id}/disable")
override fun disableStore(
@PathVariable id: Long,
): ResponseEntity<CommonApiResponse<Unit>> {
storeService.disableById(id)
return ResponseEntity.ok(CommonApiResponse())
}
}

View File

@ -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,
)

View File

@ -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<CommonApiResponse<SimpleStoreListResponse>> {
val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/stores/{id}")
override fun getStoreInfo(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<StoreInfoResponse>> {
val response = storeService.findStoreInfo(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,7 +1,5 @@
package roomescape.store.web package roomescape.store.web
import roomescape.common.dto.AuditInfo
import roomescape.region.web.RegionInfoResponse
import roomescape.store.infrastructure.persistence.StoreEntity import roomescape.store.infrastructure.persistence.StoreEntity
data class SimpleStoreResponse( data class SimpleStoreResponse(
@ -40,13 +38,3 @@ data class StoreInfoListResponse(
fun List<StoreEntity>.toInfoListResponse() = StoreInfoListResponse( fun List<StoreEntity>.toInfoListResponse() = StoreInfoListResponse(
stores = this.map { it.toInfoResponse() } 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
)