diff --git a/.gitignore b/.gitignore index 8e7f0b6..19014da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.env +data.sql application.properties HELP.md .gradle diff --git a/build.gradle.kts b/build.gradle.kts index feb9480..bc856b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + developmentOnly("org.springframework.boot:spring-boot-docker-compose") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.kotest:kotest-runner-junit5:5.8.0") testImplementation("com.appmattus.fixture:fixture:1.2.0") diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7a643e4 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,10 @@ +services: + mysql: + image: 'mysql:latest' + environment: + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + ports: + - '3306:3306' diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt new file mode 100644 index 0000000..96d53cc --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt @@ -0,0 +1,16 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatus + +enum class CoreError( + override val status: HttpStatus, + override val code: String, + override val message: String, +) : ErrorType { + UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "알 수 없는 오류가 발생했습니다."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "C002", "요청 값이 올바르지 않습니다."), + AUTHENTICATION_ERROR(HttpStatus.UNAUTHORIZED, "C003", "인증되지 않은 사용자입니다."), + AUTHORIZATION_ERROR(HttpStatus.FORBIDDEN, "C004", "접근 권한이 없습니다."), + NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "C005", "리소스를 찾을 수 없습니다."), + CONFLICT_ERROR(HttpStatus.CONFLICT, "C006", "충돌되는 리소스가 존재합니다."), +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt new file mode 100644 index 0000000..308f675 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt @@ -0,0 +1,10 @@ +package com.nilgil.commerce.common.error + +import org.springframework.boot.logging.LogLevel + +data class CoreException( + val type: ErrorType = CoreError.UNKNOWN_ERROR, + override val cause: Throwable? = null, + val logLevel: LogLevel = LogLevel.ERROR, + val detail: Any? = null, +) : RuntimeException(type.message, cause) diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt new file mode 100644 index 0000000..438581c --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt @@ -0,0 +1,14 @@ +package com.nilgil.commerce.common.error + +data class ErrorResponse( + val code: String, + val message: String, + val detail: Any? = null, +) + +fun CoreException.toResponse(): ErrorResponse = + ErrorResponse( + code = this.type.code, + message = this.type.message, + detail = this.detail, + ) diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt new file mode 100644 index 0000000..941ba51 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt @@ -0,0 +1,9 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatus + +interface ErrorType { + val status: HttpStatus + val code: String + val message: String +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..4f88f24 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt @@ -0,0 +1,50 @@ +package com.nilgil.commerce.common.error + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.boot.logging.LogLevel +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log: Logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(CoreException::class) + fun handleCoreException(e: CoreException): ResponseEntity { + log.logOnLevel( + level = e.logLevel, + message = "[ERR-${e.type.code}] ${e.message}", + throwable = e, + ) + return ResponseEntity(e.toResponse(), e.type.status) + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.UNKNOWN_ERROR, cause = e)) + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.VALIDATION_ERROR, cause = e)) + + @ExceptionHandler(IllegalStateException::class) + fun handleIllegalStateException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.CONFLICT_ERROR, cause = e)) +} + +private fun Logger.logOnLevel( + level: LogLevel, + message: String, + throwable: Throwable? = null, +) { + when (level) { + LogLevel.ERROR -> error(message, throwable) + LogLevel.WARN -> warn(message, throwable) + LogLevel.INFO -> info(message, throwable) + LogLevel.DEBUG -> debug(message, throwable) + LogLevel.TRACE -> trace(message, throwable) + else -> error(message, throwable) + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/Order.kt b/src/main/kotlin/com/nilgil/commerce/order/Order.kt index 021ab46..5a4ba40 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/Order.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/Order.kt @@ -1,6 +1,7 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.BaseEntity +import com.nilgil.commerce.common.error.CoreException import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -22,7 +23,10 @@ class Order( fun pay(paymentId: Long) { if (this.status != OrderStatus.CREATED) { - throw IllegalStateException("결제를 진행할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "결제를 진행할 수 없는 상태입니다.", + ) } this.paymentId = paymentId this.status = OrderStatus.PAID @@ -30,21 +34,30 @@ class Order( fun complete() { if (this.status != OrderStatus.PAID) { - throw IllegalStateException("완료 처리할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "완료 처리할 수 없는 상태입니다.", + ) } this.status = OrderStatus.COMPLETED } fun cancel() { if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) { - throw IllegalStateException("취소할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "취소할 수 없는 상태입니다.", + ) } this.status = OrderStatus.CANCELLED } fun returnOrder() { if (this.status != OrderStatus.COMPLETED) { - throw IllegalStateException("반품할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "반품할 수 없는 상태입니다.", + ) } this.status = OrderStatus.RETURNED } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt new file mode 100644 index 0000000..744b1f1 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.common.error.ErrorType +import org.springframework.http.HttpStatus + +enum class OrderError( + override val status: HttpStatus, + override val code: String, + override val message: String, +) : ErrorType { + INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "O001", "주문 상태를 변경할 수 없습니다."), +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductImageRepository.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductImageRepository.kt new file mode 100644 index 0000000..fa8571a --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductImageRepository.kt @@ -0,0 +1,15 @@ +package com.nilgil.commerce.product + +import org.springframework.data.jpa.repository.JpaRepository + +interface ProductImageRepository : JpaRepository { + fun findByProductAndType( + product: Product, + type: ProductImageType, + ): ProductImage? + + fun findAllByProductInAndType( + products: List, + type: ProductImageType, + ): List +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductItemController.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductItemController.kt new file mode 100644 index 0000000..7d12f0c --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductItemController.kt @@ -0,0 +1,21 @@ +package com.nilgil.commerce.product + +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 + +@RestController +class ProductItemController( + private val service: ProductItemService, +) { + @GetMapping("/product-items/{id}") + fun getProductItem( + @PathVariable id: Long, + ): ProductItemResponse = service.getProductItem(id) + + @GetMapping("/product-items") + fun getProductItems( + @RequestParam ids: List, + ): List = service.getProductItems(ids) +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductItemOptionElementRepository.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductItemOptionElementRepository.kt new file mode 100644 index 0000000..c877be2 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductItemOptionElementRepository.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.product + +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository + +interface ProductItemOptionElementRepository : JpaRepository { + @EntityGraph(attributePaths = ["optionElement.option"]) + fun findAllWithOptionDetailsByItem(item: ProductItem): List + + @EntityGraph(attributePaths = ["optionElement.option"]) + fun findAllWithOptionDetailsByItemIn(items: List): List +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductItemRepository.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductItemRepository.kt new file mode 100644 index 0000000..9b24e1a --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductItemRepository.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.product + +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository + +interface ProductItemRepository : JpaRepository { + @EntityGraph(attributePaths = ["product"]) + fun findWithProductById(productItemId: Long): ProductItem? + + @EntityGraph(attributePaths = ["product"]) + fun findWithProductByIdIn(productItemIds: List): List +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductItemResponse.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductItemResponse.kt new file mode 100644 index 0000000..704cba9 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductItemResponse.kt @@ -0,0 +1,26 @@ +package com.nilgil.commerce.product + +data class ProductItemResponse( + val id: Long, + val productName: String, + val options: Map, + val thumbnailImageUrl: String?, + val price: Int, + val stock: Int, +) { + companion object { + fun from( + productItem: ProductItem, + options: Map, + thumbnailUrl: String?, + ): ProductItemResponse = + ProductItemResponse( + id = productItem.id, + productName = productItem.product.name, + options = options, + thumbnailImageUrl = thumbnailUrl, + price = productItem.getAdjustedPrice(), + stock = productItem.stock, + ) + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/product/ProductItemService.kt b/src/main/kotlin/com/nilgil/commerce/product/ProductItemService.kt new file mode 100644 index 0000000..38594aa --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/product/ProductItemService.kt @@ -0,0 +1,76 @@ +package com.nilgil.commerce.product + +import com.nilgil.commerce.common.error.CoreError +import com.nilgil.commerce.common.error.CoreException +import org.springframework.stereotype.Service + +@Service +class ProductItemService( + private val productItemRepository: ProductItemRepository, + private val productImageRepository: ProductImageRepository, + private val productItemOptionElementRepository: ProductItemOptionElementRepository, +) { + fun getProductItem(id: Long): ProductItemResponse { + val productItem = findProductItemOrThrow(id) + val options = findOptionMap(productItem) + val thumbnailUrl = findThumbnailUrlOrNull(productItem.product) + + return ProductItemResponse.from( + productItem = productItem, + options = options, + thumbnailUrl = thumbnailUrl, + ) + } + + fun getProductItems(ids: List): List { + val productItems = productItemRepository.findWithProductByIdIn(ids) + if (productItems.isEmpty()) { + return emptyList() + } + + val thumbnailUrlMap = getProductIdThumbnailUrlMap(productItems) + val optionElementsMap = getProductItemIdOptionElementsMap(productItems) + + val responseMap = + productItems.associateBy( + keySelector = { it.id }, + valueTransform = { item -> + ProductItemResponse.from( + productItem = item, + options = optionElementsMap[item.id] ?: emptyMap(), + thumbnailUrl = thumbnailUrlMap[item.product.id], + ) + }, + ) + + return ids.mapNotNull { responseMap[it] } + } + + private fun findProductItemOrThrow(id: Long): ProductItem = + productItemRepository.findWithProductById(id) + ?: throw CoreException( + type = CoreError.NOT_FOUND_ERROR, + detail = "상품 아이템을 찾을 수 없습니다. id: $id", + ) + + private fun findOptionMap(item: ProductItem): Map = + productItemOptionElementRepository + .findAllWithOptionDetailsByItem(item) + .associate { it.optionElement.option.name to it.optionElement.name } + + private fun findThumbnailUrlOrNull(product: Product): String? = + productImageRepository.findByProductAndType(product, ProductImageType.THUMBNAIL)?.url + + private fun getProductIdThumbnailUrlMap(productItems: List): Map { + val products = productItems.map { productItem -> productItem.product } + return productImageRepository + .findAllByProductInAndType(products, ProductImageType.THUMBNAIL) + .associate { it.product.id to it.url } + } + + private fun getProductItemIdOptionElementsMap(productItems: List): Map> = + productItemOptionElementRepository + .findAllWithOptionDetailsByItemIn(productItems) + .groupBy { it.item.id } + .mapValues { it.value.associate { it -> it.optionElement.option.name to it.optionElement.name } } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3558351..87edbe8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,2 +1,14 @@ spring: application.name: generic-commerce + jpa: + open-in-view: false + hibernate: + ddl-auto: create + show-sql: true + defer-datasource-initialization: true + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + sql.init.mode: always + docker.compose.lifecycle-management: start_only diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt index 25d64c2..3fa7930 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt @@ -1,5 +1,6 @@ package com.nilgil.commerce.order +import com.nilgil.commerce.common.error.CoreException import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.DisplayName @@ -37,14 +38,17 @@ class OrderTest { } @Test - fun `CREATED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `CREATED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(paymentId = 123L) // when, then assertThatThrownBy { order.pay(paymentId = 456L) } - .isInstanceOf(IllegalStateException::class.java) + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -65,13 +69,16 @@ class OrderTest { } @Test - fun `PAID 상태가 아니면 IllegalStateException이 발생한다`() { + fun `PAID 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.complete() } - .isInstanceOf(IllegalStateException::class.java) + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -104,7 +111,7 @@ class OrderTest { } @Test - fun `COMPLETED 상태이면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -112,12 +119,14 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } @Test - fun `RETURNED 상태이면 IllegalStateException이 발생한다`() { + fun `RETURNED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -126,8 +135,10 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -149,14 +160,16 @@ class OrderTest { } @Test - fun `COMPLETED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.returnOrder() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("반품할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } }