diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/controller/ProductController.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/controller/ProductController.kt index a83a35d..81163a1 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/controller/ProductController.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/controller/ProductController.kt @@ -1,5 +1,6 @@ package com.koosco.catalogservice.api.controller +import com.koosco.catalogservice.api.request.ChangeStatusRequest import com.koosco.catalogservice.api.request.ProductCreateRequest import com.koosco.catalogservice.api.request.ProductUpdateRequest import com.koosco.catalogservice.api.response.ProductDetailResponse @@ -10,6 +11,7 @@ import com.koosco.catalogservice.application.command.FindSkuCommand import com.koosco.catalogservice.application.command.GetProductDetailCommand import com.koosco.catalogservice.application.command.GetProductListCommand import com.koosco.catalogservice.application.command.ProductSortType +import com.koosco.catalogservice.application.usecase.ChangeProductStatusUseCase import com.koosco.catalogservice.application.usecase.CreateProductUseCase import com.koosco.catalogservice.application.usecase.DeleteProductUseCase import com.koosco.catalogservice.application.usecase.FindSkuUseCase @@ -28,6 +30,7 @@ import org.springframework.data.web.PageableDefault import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping @@ -47,6 +50,7 @@ class ProductController( private val updateProductUseCase: UpdateProductUseCase, private val deleteProductUseCase: DeleteProductUseCase, private val findSkuUseCase: FindSkuUseCase, + private val changeProductStatusUseCase: ChangeProductStatusUseCase, ) { @Operation(summary = "상품 리스트를 조회합니다.", description = "필터링 조건에 따라 상품을 페이징처리하여 조회합니다.") @GetMapping @@ -141,6 +145,21 @@ class ProductController( return ApiResponse.Companion.success() } + @Operation( + summary = "상품 상태를 변경합니다.", + description = "상품의 상태를 변경합니다. 허용된 상태 전이만 가능합니다. (DRAFT→ACTIVE, ACTIVE→SUSPENDED 등)", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @PatchMapping("/{productId}/status") + fun changeProductStatus( + @Parameter(description = "Product ID") @PathVariable productId: Long, + @Valid @RequestBody request: ChangeStatusRequest, + ): ApiResponse { + changeProductStatusUseCase.execute(request.toCommand(productId)) + + return ApiResponse.Companion.success() + } + @Operation( summary = "상품을 삭제합니다.", description = "상품을 삭제합니다. 판매자만 삭제가 가능합니다.", diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/request/ProductRequests.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/request/ProductRequests.kt index 10f71ed..18b28ff 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/request/ProductRequests.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/api/request/ProductRequests.kt @@ -1,5 +1,6 @@ package com.koosco.catalogservice.api.request +import com.koosco.catalogservice.application.command.ChangeProductStatusCommand import com.koosco.catalogservice.application.command.CreateProductCommand import com.koosco.catalogservice.application.command.UpdateProductCommand import com.koosco.catalogservice.domain.enums.ProductStatus @@ -81,7 +82,6 @@ data class ProductUpdateRequest( val description: String?, @field:Min(value = 0, message = "Price must be non-negative") val price: Long?, - val status: ProductStatus?, val categoryId: Long?, val thumbnailImageUrl: String?, val brandId: Long?, @@ -91,9 +91,21 @@ data class ProductUpdateRequest( name = name, description = description, price = price, - status = status, categoryId = categoryId, thumbnailImageUrl = thumbnailImageUrl, brandId = brandId, ) } + +/** + * Change Status Request + */ +data class ChangeStatusRequest( + @field:NotNull(message = "Status is required") + val status: ProductStatus, +) { + fun toCommand(productId: Long): ChangeProductStatusCommand = ChangeProductStatusCommand( + productId = productId, + status = status, + ) +} diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/command/ProductCommand.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/command/ProductCommand.kt index 571ddd8..a9fbada 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/command/ProductCommand.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/command/ProductCommand.kt @@ -22,10 +22,11 @@ data class UpdateProductCommand( val name: String?, val description: String?, val price: Long?, - val status: ProductStatus?, val categoryId: Long?, val thumbnailImageUrl: String?, val brandId: Long?, ) data class DeleteProductCommand(val productId: Long) + +data class ChangeProductStatusCommand(val productId: Long, val status: ProductStatus) diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/port/IntegrationEventProducer.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/port/IntegrationEventProducer.kt index 6aa5aed..952e56d 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/port/IntegrationEventProducer.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/port/IntegrationEventProducer.kt @@ -1,13 +1,7 @@ package com.koosco.catalogservice.application.port -import com.koosco.catalogservice.contract.ProductIntegrationEvent +import com.koosco.catalogservice.contract.CatalogIntegrationEvent -/** - * fileName : IntegrationEventProducer - * author : koo - * date : 2025. 12. 19. 오후 1:45 - * description : - */ interface IntegrationEventProducer { - fun publish(event: ProductIntegrationEvent) + fun publish(event: CatalogIntegrationEvent) } diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/ChangeProductStatusUseCase.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/ChangeProductStatusUseCase.kt new file mode 100644 index 0000000..5d43a51 --- /dev/null +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/ChangeProductStatusUseCase.kt @@ -0,0 +1,48 @@ +package com.koosco.catalogservice.application.usecase + +import com.koosco.catalogservice.application.command.ChangeProductStatusCommand +import com.koosco.catalogservice.application.port.IntegrationEventProducer +import com.koosco.catalogservice.application.port.ProductRepository +import com.koosco.catalogservice.common.error.CatalogErrorCode +import com.koosco.catalogservice.contract.outbound.ProductStatusChangedEvent +import com.koosco.common.core.annotation.UseCase +import com.koosco.common.core.exception.BadRequestException +import com.koosco.common.core.exception.NotFoundException +import org.springframework.cache.annotation.CacheEvict +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@UseCase +class ChangeProductStatusUseCase( + private val productRepository: ProductRepository, + private val integrationEventProducer: IntegrationEventProducer, +) { + + @CacheEvict(cacheNames = ["productDetail"], key = "#command.productId") + @Transactional + fun execute(command: ChangeProductStatusCommand) { + val product = productRepository.findOrNull(command.productId) + ?: throw NotFoundException(CatalogErrorCode.PRODUCT_NOT_FOUND) + + val previousStatus = product.status + + try { + product.changeStatus(command.status) + } catch (e: IllegalArgumentException) { + throw BadRequestException( + CatalogErrorCode.INVALID_STATUS_TRANSITION, + e.message ?: CatalogErrorCode.INVALID_STATUS_TRANSITION.message, + ) + } + + integrationEventProducer.publish( + ProductStatusChangedEvent( + productId = product.id!!, + productCode = product.productCode, + previousStatus = previousStatus, + newStatus = command.status, + changedAt = LocalDateTime.now(), + ), + ) + } +} diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/UpdateProductUseCase.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/UpdateProductUseCase.kt index d9a35b1..85e0817 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/UpdateProductUseCase.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/application/usecase/UpdateProductUseCase.kt @@ -21,7 +21,6 @@ class UpdateProductUseCase(private val productRepository: ProductRepository) { name = command.name, description = command.description, price = command.price, - status = command.status, categoryId = command.categoryId, thumbnailImageUrl = command.thumbnailImageUrl, brandId = command.brandId, diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/config/KafkaTopicResolver.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/config/KafkaTopicResolver.kt index e1fcab8..8cb30fc 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/config/KafkaTopicResolver.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/config/KafkaTopicResolver.kt @@ -1,18 +1,12 @@ package com.koosco.catalogservice.common.config -import com.koosco.catalogservice.contract.ProductIntegrationEvent +import com.koosco.catalogservice.contract.CatalogIntegrationEvent import com.koosco.catalogservice.infra.messaging.TopicResolver import org.springframework.stereotype.Component -/** - * fileName : KafkaTopicResolver - * author : koo - * date : 2025. 12. 22. 오전 4:42 - * description : - */ @Component class KafkaTopicResolver(private val props: KafkaTopicProperties) : TopicResolver { - override fun resolve(event: ProductIntegrationEvent): String = props.mappings[event.getEventType()] + override fun resolve(event: CatalogIntegrationEvent): String = props.mappings[event.getEventType()] ?: props.default } diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/error/CatalogErrorCode.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/error/CatalogErrorCode.kt index d6e3e9d..1686055 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/error/CatalogErrorCode.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/common/error/CatalogErrorCode.kt @@ -15,6 +15,8 @@ enum class CatalogErrorCode(override val code: String, override val message: Str INVALID_SORT_OPTION("CATALOG-400-003", "지원하지 않는 정렬 옵션입니다.", HttpStatus.BAD_REQUEST), INVALID_PRICE_RANGE("CATALOG-400-004", "가격 범위가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), INVALID_SEARCH_QUERY("CATALOG-400-005", "검색어 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + INVALID_STATUS_TRANSITION("CATALOG-400-006", "허용되지 않는 상태 전이입니다.", HttpStatus.BAD_REQUEST), + PRODUCT_NOT_READY_FOR_ACTIVATION("CATALOG-400-007", "상품이 활성화 조건을 충족하지 않습니다.", HttpStatus.BAD_REQUEST), // 401 Unauthorized UNAUTHORIZED("CATALOG-401-001", "상품 정보를 조회하려면 인증이 필요합니다.", HttpStatus.UNAUTHORIZED), diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/CatalogIntegrationEvent.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/CatalogIntegrationEvent.kt new file mode 100644 index 0000000..08f7ec3 --- /dev/null +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/CatalogIntegrationEvent.kt @@ -0,0 +1,20 @@ +package com.koosco.catalogservice.contract + +import com.koosco.common.core.event.CloudEvent + +interface CatalogIntegrationEvent { + fun getAggregateId(): String + + fun getEventType(): String + + fun getPartitionKey(): String = getAggregateId() + + fun getSubject(): String + + fun toCloudEvent(source: String): CloudEvent = CloudEvent.of( + source = source, + type = getEventType(), + subject = getSubject(), + data = this, + ) +} diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/ProductIntegrationEvent.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/ProductIntegrationEvent.kt index bca3e10..4157439 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/ProductIntegrationEvent.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/ProductIntegrationEvent.kt @@ -1,39 +1,11 @@ package com.koosco.catalogservice.contract -import com.koosco.common.core.event.CloudEvent - -/** - * fileName : CatalogIntegrationEvent - * author : koo - * date : 2025. 12. 22. 오전 9:30 - * description : - */ -interface ProductIntegrationEvent { +interface ProductIntegrationEvent : CatalogIntegrationEvent { val skuId: String - /** - * CloudEvent type - * 예: stock.reserve.failed - */ - fun getEventType(): String - - /** - * Kafka partition key - */ - fun getPartitionKey(): String = skuId + override fun getAggregateId(): String = skuId - /** - * CloudEvent subject (선택) - */ - fun getSubject(): String = "sku/$skuId" + override fun getEventType(): String - /** - * CloudEvent 변환 (공통) - */ - fun toCloudEvent(source: String): CloudEvent = CloudEvent.of( - source = source, - type = getEventType(), - subject = getSubject(), - data = this, - ) + override fun getSubject(): String = "sku/$skuId" } diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/outbound/ProductStatusChangedEvent.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/outbound/ProductStatusChangedEvent.kt new file mode 100644 index 0000000..df7b8b6 --- /dev/null +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/contract/outbound/ProductStatusChangedEvent.kt @@ -0,0 +1,19 @@ +package com.koosco.catalogservice.contract.outbound + +import com.koosco.catalogservice.contract.CatalogIntegrationEvent +import com.koosco.catalogservice.domain.enums.ProductStatus +import java.time.LocalDateTime + +data class ProductStatusChangedEvent( + val productId: Long, + val productCode: String, + val previousStatus: ProductStatus, + val newStatus: ProductStatus, + val changedAt: LocalDateTime, +) : CatalogIntegrationEvent { + override fun getAggregateId(): String = productId.toString() + + override fun getEventType(): String = "product.status.changed" + + override fun getSubject(): String = "product/$productId" +} diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/entity/Product.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/entity/Product.kt index 594fc5c..1ff120b 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/entity/Product.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/entity/Product.kt @@ -73,7 +73,6 @@ class Product( name: String?, description: String?, price: Long?, - status: ProductStatus?, categoryId: Long?, thumbnailImageUrl: String?, brandId: Long?, @@ -81,14 +80,35 @@ class Product( name?.let { this.name = it } description?.let { this.description = it } price?.let { this.price = it } - status?.let { this.status = it } categoryId?.let { this.categoryId = it } thumbnailImageUrl?.let { this.thumbnailImageUrl = it } brandId?.let { this.brandId = it } } + fun changeStatus(newStatus: ProductStatus) { + require(status.canTransitionTo(newStatus)) { + "상품 상태를 ${status.name}에서 ${newStatus.name}(으)로 변경할 수 없습니다." + } + + if (newStatus == ProductStatus.ACTIVE) { + validateForActivation() + } + + this.status = newStatus + } + fun delete() { - this.status = ProductStatus.DELETED + if (status == ProductStatus.DRAFT) { + this.status = ProductStatus.DELETED + return + } + changeStatus(ProductStatus.DELETED) + } + + private fun validateForActivation() { + require(name.isNotBlank()) { "상품명이 비어있습니다." } + require(price > 0) { "가격은 0보다 커야 합니다." } + require(skus.isNotEmpty()) { "SKU가 1개 이상 있어야 활성화할 수 있습니다." } } fun addSkus(skus: List) { diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/enums/ProductStatus.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/enums/ProductStatus.kt index 4212d53..b618fc7 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/enums/ProductStatus.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/domain/enums/ProductStatus.kt @@ -1,7 +1,18 @@ package com.koosco.catalogservice.domain.enums enum class ProductStatus { + DRAFT, ACTIVE, - INACTIVE, + SUSPENDED, + OUT_OF_STOCK, DELETED, + ; + + fun canTransitionTo(target: ProductStatus): Boolean = when (this) { + DRAFT -> target == ACTIVE + ACTIVE -> target in setOf(SUSPENDED, OUT_OF_STOCK, DELETED) + SUSPENDED -> target in setOf(ACTIVE, DELETED) + OUT_OF_STOCK -> target in setOf(ACTIVE, DELETED) + DELETED -> false + } } diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/TopicResolver.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/TopicResolver.kt index 49c7e52..0abc282 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/TopicResolver.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/TopicResolver.kt @@ -1,13 +1,7 @@ package com.koosco.catalogservice.infra.messaging -import com.koosco.catalogservice.contract.ProductIntegrationEvent +import com.koosco.catalogservice.contract.CatalogIntegrationEvent -/** - * fileName : IntegrationTopicResolver - * author : koo - * date : 2025. 12. 19. 오후 3:00 - * description : - */ interface TopicResolver { - fun resolve(event: ProductIntegrationEvent): String + fun resolve(event: CatalogIntegrationEvent): String } diff --git a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/kafka/producer/OutboxIntegrationEventProducer.kt b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/kafka/producer/OutboxIntegrationEventProducer.kt index 6e2f535..25b7dc5 100644 --- a/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/kafka/producer/OutboxIntegrationEventProducer.kt +++ b/services/catalog-service/src/main/kotlin/com/koosco/catalogservice/infra/messaging/kafka/producer/OutboxIntegrationEventProducer.kt @@ -3,7 +3,7 @@ package com.koosco.catalogservice.infra.messaging.kafka.producer import com.fasterxml.jackson.databind.ObjectMapper import com.koosco.catalogservice.application.port.IntegrationEventProducer import com.koosco.catalogservice.common.config.KafkaTopicResolver -import com.koosco.catalogservice.contract.ProductIntegrationEvent +import com.koosco.catalogservice.contract.CatalogIntegrationEvent import com.koosco.catalogservice.domain.entity.CatalogOutboxEntry import com.koosco.catalogservice.infra.outbox.CatalogOutboxRepository import org.slf4j.LoggerFactory @@ -31,7 +31,7 @@ class OutboxIntegrationEventProducer( private val logger = LoggerFactory.getLogger(javaClass) - override fun publish(event: ProductIntegrationEvent) { + override fun publish(event: CatalogIntegrationEvent) { val cloudEvent = event.toCloudEvent(source) val topic = topicResolver.resolve(event) val partitionKey = event.getPartitionKey() @@ -41,14 +41,14 @@ class OutboxIntegrationEventProducer( objectMapper.writeValueAsString(cloudEvent) } catch (e: Exception) { logger.error( - "Failed to serialize CloudEvent: type=$eventType, skuId=${event.skuId}", + "Failed to serialize CloudEvent: type=$eventType, aggregateId=${event.getAggregateId()}", e, ) throw e } val outboxEntry = CatalogOutboxEntry.create( - aggregateId = event.skuId, + aggregateId = event.getAggregateId(), eventType = eventType, payload = payload, topic = topic, @@ -58,7 +58,7 @@ class OutboxIntegrationEventProducer( outboxRepository.save(outboxEntry) logger.info( - "Outbox entry saved: type=$eventType, skuId=${event.skuId}, topic=$topic", + "Outbox entry saved: type=$eventType, aggregateId=${event.getAggregateId()}, topic=$topic", ) } } diff --git a/services/catalog-service/src/main/resources/application.yaml b/services/catalog-service/src/main/resources/application.yaml index bbe21ab..e3785a7 100644 --- a/services/catalog-service/src/main/resources/application.yaml +++ b/services/catalog-service/src/main/resources/application.yaml @@ -63,6 +63,7 @@ catalog: default: koosco.commerce.catalog.default mappings: "product.sku.created": koosco.commerce.product.default + "product.status.changed": koosco.commerce.product.default common: openapi: