Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Any> {
changeProductStatusUseCase.execute(request.toCommand(productId))

return ApiResponse.Companion.success()
}

@Operation(
summary = "상품을 삭제합니다.",
description = "상품을 삭제합니다. 판매자만 삭제가 가능합니다.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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?,
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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(),
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<out CatalogIntegrationEvent> = CloudEvent.of(
source = source,
type = getEventType(),
subject = getSubject(),
data = this,
)
}
Original file line number Diff line number Diff line change
@@ -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<ProductIntegrationEvent> = CloudEvent.of(
source = source,
type = getEventType(),
subject = getSubject(),
data = this,
)
override fun getSubject(): String = "sku/$skuId"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,42 @@ class Product(
name: String?,
description: String?,
price: Long?,
status: ProductStatus?,
categoryId: Long?,
thumbnailImageUrl: String?,
brandId: Long?,
) {
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<ProductSku>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading