Skip to content

Commit

Permalink
refactor: 결제 리팩토링 (#113)
Browse files Browse the repository at this point in the history
* refactor: Money 값객체 생성 및 적용

* refactor: OrderPayment에 status 추가

* refactor: ProductSnapshot 생성 및 적용

* refactor: OrderService 메서드 분리

* hotfix: prod환경 redis connection 정보 추가

* feature: 알림 조회 및 읽음 처리 api (#114)

* feat: 알림 도메인 추가

* feat: 알림 전체 조회 기능 추가

* feat: 알림 전체 조회 API 추가

* feat: 읽지 않은 알림 개수 조회 로직 추가

* feat: 읽지 않은 알림 개수 조회 API 추가

* feat: 알림 확인 로직 추가

* feat: 알림 확인 API 추가

* style: Annotation 순서 수정

* refactor: query method 네이밍 수정

* test: 검증부 추가

* chore: 알림 관련 더미 데이터 추가

* fix: 봉달 상품 수정시 수량을 포함 하여 중복 검사 하도록 수정 (#117)

* refactor: 스웨거 명세 추가

* refactor: 상품 상세 조회, 장바구니 조회시 storeId를 반환하게 변경

* refactor: DeliveryGroupKey 값객체 생성

* refactor: ProductSnapshotException을 ProductException으로 변경

* refactor: 메서드 이름 변경

* refactor: 초기 데이터에 ProductSnapshot 저장 추가

* refactor: 메서드명 변경

* refactor: OrderShippingAddress를 nullable로 변경

* refactor: 최근 ProductSnapshot들만 조회하게 변경

* refactor: OrderProducts 검증 로직 분리

* feature: 로그아웃 api (#119)

* feat: 로그아웃 시 회원 데이터 변경 로직 추가

* feat: 로그아웃 로직 추가

* feat: 로그아웃 API 추가

* feat: 블랙리스트 토큰 캐시 스토리지 추가

* feat: 로그아웃 시 사용중인 토큰 블랙리스트 추가 로직 적용

* feat: 인증 요청에 대한 블랙리스트 토큰 조회 로직 추가

* test: Redis cleaner 방식 수정

* refactor: 일관성 있는 method 이름으로 수정

* refactor: 사용 하지 않는 파라미터 제거

* fix: 토큰만 사용 되는 API 요청에 대한 로그아웃 블랙리스트 검증 로직 추가

* feature: 회원가입 api 개발 (#120)

* feat: Fish entity 설계

* feat: Fish repository 조회 기능 추가

* feat: FishService 조회 기능 추가

* feat: FishController 어종 자동완성 검색 API 추가

* feat: FishTankName 검증 기능 추가

* feat: FishTank 추가

* feat: FishLifeYear 검증 기능 추가

* feat: PetFish 기능 추가

* refactor: 카카오 프로필 받지 않도록 변경

* feat: MemberDetail 추가

* chore: rebase 충돌 해결

* refactor: 어종 이름 자동완성 검색 로직 수정

* refactor: FishTank memberId 필드명 변경

* feat: Member update 메서드 추가

* feat: PetFishSex, TankSize 정적 팩터리 메서드 추가

* feat: PetFish 일급컬렉션 PetFishes 추가

* feat: BannedWord 객체 추가

* feat: NicknameWord 객체 추가

* feat: Nickname 객체 추가

* feat: NicknameGenerator generate 기능 추가

* chore: rebase 충돌 해결

* refactor: 로그인 api 상태코드 변경 - 회원가입이 필요한지 여부를 상태코드로 분기

* chore: rebase 충돌 해결

* chore: rebase 충돌 해결

* refactor: 랜덤 닉네임 설정 시 숫자 생성 정책 반영

* feat: 이름에 금지 단어 포함 여부 검증 기능 추가

* feat: 회원 물생활 프로필 입력 API 추가

* feat: 이름 검증 API 추가

* chore: rebase 충돌 해결

* refactor: 회원가입 토큰 커스텀 헤더 관리 기능 추가, 스웨거 작업

* fix: 예외 처리 테스트 기댓값 알맞게 수정

* chore: rebase 충돌 해결

* fix: 회원 삭제 로직 memberId 로 동작하도록 변경

* refactor: 회원 랜덤 닉네임 생성 시 시도 횟수 제한

* refactor: AuthMember 이름 AuthCredentials 로 변경

* refactor: 회원가입 API, 이름 검증 API 응답 상태코드 변경

* chore: rebase 충돌 해결

* refactor: 불필요한 로직 및 메서드 삭제

* refactor: 랜덤 닉네임 생성 실패 시 예외 코드 변경

* fix: Fish 개수를 통한 검증 로직 수정

* refactor: Fish 자동완성 검색 시 정렬 순서 변경

* fix: 이전 PR에 AuthCredentials, Member에 관한 코드 변경사항 반영

* refactor: 로그인 토큰과 회원가입 토큰을 AuthToken 으로 추상화

* refactor: ProductSnapshot 생성 및 적용

* refactor: 스웨거 설정 추가 및 사용하지 않는 필드 제거

* refactor: 리스트 필드에 스웨거 설정 추가

* refactor: 긴 로직을 변수로 분리

* refactor: 주문 배송지 유효성 검사 로직 수정

* refactor: 주문 상품 존재 유효성 검증 로직 변경

* refactor: 주문 옵션 유효성 검증 로직 변경

* refactor: OrderProducts 유효성 검사를 하나의 메서드로 묶어 제공

* refactor: 메서드 이름 변경

* refactor: 예외 코드 변경

* refactor: OrderProducts 유효성 검증 로직 추가

* refactor: 메서드 이름 변경

* refactor: Order의 status 삭제

* test: OrderPayment 테스트 추가

* refactor: OrderProducts 검증 로직 수정

* test: Order Controller 테스트 추가

* refactor: Order 조회 시 목록으로 조회되도록 수정

* refactor: OrderException 타입 에러 수정

* refactor: 예외 코드 이름 변경

---------

Co-authored-by: TaeyeonRoyce <[email protected]>
Co-authored-by: Taeyeon <[email protected]>
Co-authored-by: Chanmin Ju(Hu chu) <[email protected]>
  • Loading branch information
4 people authored Apr 14, 2024
1 parent 1dfa139 commit ea96561
Show file tree
Hide file tree
Showing 45 changed files with 1,693 additions and 212 deletions.
14 changes: 12 additions & 2 deletions src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.petqua.domain.cart.CartProductQuantity
import com.petqua.domain.delivery.DeliveryMethod
import com.petqua.domain.product.Product
import com.petqua.domain.product.option.Sex
import com.petqua.domain.store.Store
import io.swagger.v3.oas.annotations.media.Schema
import java.math.BigDecimal

Expand Down Expand Up @@ -51,6 +52,12 @@ data class CartProductWithSupportedOptionResponse(
)
val id: Long,

@Schema(
description = "상품 판매점 id",
example = "1"
)
val storeId: Long,

@Schema(
description = "상품 판매점",
example = "S아쿠아"
Expand Down Expand Up @@ -163,6 +170,7 @@ data class CartProductWithSupportedOptionResponse(
femaleAdditionalPrice: Money?,
) : this(
id = cartProductResponse.id,
storeId = cartProductResponse.storeId,
storeName = cartProductResponse.storeName,
productId = cartProductResponse.productId,
productName = cartProductResponse.productName,
Expand All @@ -185,6 +193,7 @@ data class CartProductWithSupportedOptionResponse(

data class CartProductResponse(
val id: Long,
val storeId: Long,
val storeName: String,
val productId: Long,
val productName: String,
Expand All @@ -205,10 +214,11 @@ data class CartProductResponse(
constructor(
cartProduct: CartProduct,
product: Product?,
storeName: String?,
store: Store?,
) : this(
id = cartProduct.id,
storeName = storeName ?: "",
storeId = store?.id ?: 0L,
storeName = store?.name ?: "",
productId = product?.id ?: 0L,
productName = product?.name ?: "",
productThumbnailUrl = product?.thumbnailUrl ?: "",
Expand Down
126 changes: 126 additions & 0 deletions src/main/kotlin/com/petqua/application/order/OrderProductsValidator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.petqua.application.order

import com.petqua.application.order.dto.OrderProductCommand
import com.petqua.common.domain.Money
import com.petqua.common.util.throwExceptionWhen
import com.petqua.domain.order.DeliveryGroupKey
import com.petqua.domain.product.Product
import com.petqua.domain.product.option.ProductOption
import com.petqua.exception.order.OrderException
import com.petqua.exception.order.OrderExceptionType
import com.petqua.exception.product.ProductException
import com.petqua.exception.product.ProductExceptionType

class OrderProductsValidator(
private val productById: Map<Long, Product>,
private val productOptions: Set<ProductOption>,
private val products: List<Product>,
) {

constructor(productById: Map<Long, Product>, productOptions: Set<ProductOption>) : this(
productById,
productOptions,
productById.values.toList()
)

fun validate(totalAmount: Money, orderProductCommands: List<OrderProductCommand>) {
validateProductsIsExist(orderProductCommands)
validateProductOptionsIsExist(orderProductCommands)
validateProductDetailIsMatching(orderProductCommands)
validateOrderProductPrices(orderProductCommands)
validateTotalAmount(totalAmount, orderProductCommands)
}

fun validateProductsIsExist(orderProductCommands: List<OrderProductCommand>) {
val productCommandIds = orderProductCommands.map { it.productId }.toSet()
val productIds = productById.keys
throwExceptionWhen(productCommandIds != productIds) { OrderException(OrderExceptionType.PRODUCT_NOT_FOUND) }
}

fun validateProductOptionsIsExist(orderProductCommands: List<OrderProductCommand>) {
orderProductCommands.forEach { orderProductCommand ->
val productOption = productOptions.findOptionBy(orderProductCommand.productId)
throwExceptionWhen(!productOption.isSame(orderProductCommand.toProductOption())) {
ProductException(ProductExceptionType.INVALID_PRODUCT_OPTION)
}
}
}

fun validateProductDetailIsMatching(orderProductCommands: List<OrderProductCommand>) {
orderProductCommands.forEach { orderProductCommand ->
val product = productById[orderProductCommand.productId]
?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND)
throwExceptionWhen(!product.isDetailMatching(orderProductCommand)) {
OrderException(OrderExceptionType.PRODUCT_INFO_NOT_MATCH)
}
}
}

fun validateOrderProductPrices(orderProductCommands: List<OrderProductCommand>) {
orderProductCommands.forEach { orderProductCommand ->
val product = productById[orderProductCommand.productId]
?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND)
val productOption = productOptions.findOptionBy(orderProductCommand.productId)
validateOrderProductPrice(product, productOption, orderProductCommand)
}
}

fun validateTotalAmount(inputTotalAmount: Money, orderProductCommands: List<OrderProductCommand>) {
val totalDeliveryFee = calculateTotalDeliveryFee(orderProductCommands)
throwExceptionWhen(inputTotalAmount != Money.from(totalDeliveryFee.toBigDecimal() + orderProductCommands.sumOf { it.orderPrice.value })) {
OrderException(
OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH
)
}
}

private fun validateOrderProductPrice(product: Product, option: ProductOption, command: OrderProductCommand) {
val expectedOrderPrice = (product.discountPrice + option.additionalPrice) * command.quantity.toBigDecimal()
val expectedDeliveryFee = product.getDeliveryFee(command.deliveryMethod)
if (command.orderPrice != expectedOrderPrice || command.deliveryFee != expectedDeliveryFee) {
throw OrderException(OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH)
}
}

private fun calculateTotalDeliveryFee(orderProductCommands: List<OrderProductCommand>): Int {
return products.groupBy { getDeliveryGroupKey(it, orderProductCommands) }
.map { getDeliveryGroupFee(it) }
.sum()
}

private fun getDeliveryGroupKey(
product: Product,
orderProductCommands: List<OrderProductCommand>
): DeliveryGroupKey {
val deliveryMethod = orderProductCommands.find { it.productId == product.id }?.deliveryMethod
?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND)
return DeliveryGroupKey(product.storeId, deliveryMethod)
}

private fun getDeliveryGroupFee(deliveryGroup: Map.Entry<DeliveryGroupKey, List<Product>>): Int {
val productOfDeliveryGroup = deliveryGroup.value.first()
val deliveryMethod = deliveryGroup.key.deliveryMethod
return productOfDeliveryGroup.getDeliveryFee(deliveryMethod).value.toInt()
}

private fun Set<ProductOption>.findOptionBy(productId: Long): ProductOption {
return find { it.productId == productId }
?: throw ProductException(ProductExceptionType.INVALID_PRODUCT_OPTION)
}

private fun Product.isDetailMatching(orderProductCommand: OrderProductCommand): Boolean {
if (price != orderProductCommand.originalPrice) {
return false
}
if (discountRate != orderProductCommand.discountRate) {
return false
}
if (discountPrice != orderProductCommand.discountPrice) {
return false
}
if (getDeliveryFee(orderProductCommand.deliveryMethod) != orderProductCommand.deliveryFee) {
return false
}
return true
}
}
140 changes: 68 additions & 72 deletions src/main/kotlin/com/petqua/application/order/OrderService.kt
Original file line number Diff line number Diff line change
@@ -1,137 +1,133 @@
package com.petqua.application.order

import com.petqua.application.order.dto.OrderProductCommand
import com.petqua.application.order.dto.SaveOrderCommand
import com.petqua.application.order.dto.SaveOrderResponse
import com.petqua.application.payment.infra.PaymentGatewayClient
import com.petqua.common.domain.Money
import com.petqua.common.domain.findByIdOrThrow
import com.petqua.common.util.throwExceptionWhen
import com.petqua.domain.delivery.DeliveryMethod.PICK_UP
import com.petqua.domain.order.Order
import com.petqua.domain.order.OrderName
import com.petqua.domain.order.OrderNumber
import com.petqua.domain.order.OrderPayment
import com.petqua.domain.order.OrderPaymentRepository
import com.petqua.domain.order.OrderRepository
import com.petqua.domain.order.OrderShippingAddress
import com.petqua.domain.order.OrderStatus.ORDER_CREATED
import com.petqua.domain.order.ShippingAddress
import com.petqua.domain.order.ShippingAddressRepository
import com.petqua.domain.order.ShippingNumber
import com.petqua.domain.product.Product
import com.petqua.domain.product.ProductRepository
import com.petqua.domain.product.ProductSnapshot
import com.petqua.domain.product.ProductSnapshotRepository
import com.petqua.domain.product.option.ProductOptionRepository
import com.petqua.domain.store.StoreRepository
import com.petqua.exception.order.OrderException
import com.petqua.exception.order.OrderExceptionType.ORDER_PRICE_NOT_MATCH
import com.petqua.exception.order.OrderExceptionType.EMPTY_SHIPPING_ADDRESS
import com.petqua.exception.order.OrderExceptionType.PRODUCT_NOT_FOUND
import com.petqua.exception.order.OrderExceptionType.STORE_NOT_FOUND
import com.petqua.exception.order.ShippingAddressException
import com.petqua.exception.order.ShippingAddressExceptionType.NOT_FOUND_SHIPPING_ADDRESS
import com.petqua.exception.product.ProductException
import com.petqua.exception.product.ProductExceptionType.INVALID_PRODUCT_OPTION
import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Transactional
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val orderPaymentRepository: OrderPaymentRepository,
private val productRepository: ProductRepository,
private val productOptionRepository: ProductOptionRepository,
private val productSnapshotRepository: ProductSnapshotRepository,
private val shippingAddressRepository: ShippingAddressRepository,
private val storeRepository: StoreRepository,
private val paymentGatewayClient: PaymentGatewayClient,
) {

fun save(command: SaveOrderCommand): SaveOrderResponse {
// TODO 상품 존재 검증
val productIds = command.orderProductCommands.map { it.productId }
val productById = productRepository.findAllByIsDeletedFalseAndIdIn(productIds).associateBy { it.id }
val products = productById.map { it.value }

throwExceptionWhen(products.size != productIds.size) { OrderException(PRODUCT_NOT_FOUND) }
validateOrderProducts(command, productById)
val shippingAddress = findValidateShippingAddress(command.shippingAddressId, command.orderProductCommands)
val productSnapshots = findValidateProductSnapshots(productById)

// TODO 상품 유효성 검증 - 올바른 옵션 매칭인가?
val productOptions = productOptionRepository.findByProductIdIn(productIds)

command.orderProductCommands.forEach { productOptionCommand ->
productOptions.find { it.productId == productOptionCommand.productId }?.let {
throwExceptionWhen(!it.isSame(productOptionCommand.toProductOption())) {
ProductException(INVALID_PRODUCT_OPTION)
}
} ?: throw ProductException(INVALID_PRODUCT_OPTION)
}

// TODO 배송지 존재 검증
val shippingAddress = shippingAddressRepository.findByIdOrThrow(command.shippingAddressId) {
ShippingAddressException(NOT_FOUND_SHIPPING_ADDRESS)
}
// TODO: TODO 재고 검증
val orders = saveOrders(command, productSnapshots, shippingAddress)
orderPaymentRepository.saveAll(orders.map { OrderPayment.from(it) })
return SaveOrderResponse(
orderId = orders.first().orderNumber.value,
orderName = orders.first().orderName.value,
)
}

// TODO 총 가격 검증
// 1. 상품 가격
command.orderProductCommands.forEach { productCommand ->
val product = productById[productCommand.productId]
?: throw OrderException(PRODUCT_NOT_FOUND)
val productOption = productOptions.find { it.productId == product.id }
?: throw ProductException(INVALID_PRODUCT_OPTION)
private fun validateOrderProducts(command: SaveOrderCommand, productById: Map<Long, Product>) {
val productOptions = productOptionRepository.findByProductIdIn(productById.keys.toList())
val orderProductsValidator = OrderProductsValidator(productById, productOptions.toSet())
orderProductsValidator.validate(command.totalAmount, command.orderProductCommands)
}

throwExceptionWhen(
productCommand.orderPrice != (product.discountPrice + productOption.additionalPrice) * productCommand.quantity.toBigDecimal()
|| productCommand.deliveryFee != product.getDeliveryFee(productCommand.deliveryMethod)
) {
OrderException(
ORDER_PRICE_NOT_MATCH
)
private fun findValidateShippingAddress(
shippingAddressId: Long?,
orderProductCommands: List<OrderProductCommand>
): ShippingAddress? {
if (shippingAddressId == null) {
throwExceptionWhen(orderProductCommands.any { it.deliveryMethod != PICK_UP }) {
OrderException(EMPTY_SHIPPING_ADDRESS)
}
return null
}

// 3. 총 배송비 검증 (스토어로 묶인 뒤 배송비 검증)
val groupBy = products.groupBy { product ->
Pair(
product.storeId,
command.orderProductCommands.find { it.productId == product.id }?.deliveryMethod
?: throw OrderException(PRODUCT_NOT_FOUND)
)
return shippingAddressRepository.findByIdOrThrow(shippingAddressId) {
ShippingAddressException(NOT_FOUND_SHIPPING_ADDRESS)
}
val orderDeliveryFee = groupBy.map { (storeDeliveryMethod, products) ->
val deliveryMethod = storeDeliveryMethod.second
products.first().getDeliveryFee(deliveryMethod).value.toInt() // TODO int로 바꾼 이유?
}.sum()
}

// 4. 총 결제 금액 검증
throwExceptionWhen(command.totalAmount != Money.from(orderDeliveryFee.toBigDecimal() + command.orderProductCommands.sumOf { it.orderPrice.value })) {
OrderException(
ORDER_PRICE_NOT_MATCH
)
private fun findValidateProductSnapshots(productById: Map<Long, Product>): Map<Long, ProductSnapshot> {
val productIds = productById.keys.toList()
val products = productById.values.toList()
val productSnapshots = productSnapshotRepository.findLatestAllByProductIdIn(productIds).associateBy { it.productId }
products.forEach { product ->
productSnapshots[product.id]?.takeIf { it.isProductDetailsMatching(product) }
?: throw ProductException(NOT_FOUND_PRODUCT)
}
return productSnapshots
}

// TODO: TODO 재고 검증

val storesById = storeRepository.findByIdIn(products.map { it.storeId }).associateBy { it.id }
private fun saveOrders(
command: SaveOrderCommand,
productSnapshotsById: Map<Long, ProductSnapshot>,
shippingAddress: ShippingAddress?
): List<Order> {
val productSnapshots = productSnapshotsById.values.toList()
val storesById = storeRepository.findByIdIn(productSnapshots.map { it.storeId }).associateBy { it.id }
val orderNumber = OrderNumber.generate()
val orderName = OrderName.from(products)
// TODO 주문 저장 로직
val orderName = OrderName.from(productSnapshots)
val orders = command.orderProductCommands.map { productCommand ->
val product = productById[productCommand.productId]
val productSnapshot = productSnapshotsById[productCommand.productId]
?: throw OrderException(PRODUCT_NOT_FOUND)

val orderShippingAddress = shippingAddress?.let { OrderShippingAddress.from(it, command.shippingRequest) }
Order(
memberId = command.memberId,
orderNumber = orderNumber,
orderName = orderName,
orderShippingAddress = OrderShippingAddress.from(shippingAddress, command.shippingRequest),
orderShippingAddress = orderShippingAddress,
orderProduct = productCommand.toOrderProduct(
shippingNumber = ShippingNumber.of(product.storeId, productCommand.deliveryMethod, orderNumber),
product = product,
storeName = storesById[product.storeId]?.name ?: throw OrderException(PRODUCT_NOT_FOUND),
shippingNumber = ShippingNumber.of(
productSnapshot.storeId,
productCommand.deliveryMethod,
orderNumber
),
productSnapshot = productSnapshot,
storeName = storesById[productSnapshot.storeId]?.name ?: throw OrderException(STORE_NOT_FOUND),
),
isAbleToCancel = true,
status = ORDER_CREATED,
totalAmount = command.totalAmount,
)
}
orderRepository.saveAll(orders)

return SaveOrderResponse(
orderId = orders.first().orderNumber.value,
orderName = orders.first().orderName.value,
successUrl = paymentGatewayClient.successUrl(),
failUrl = paymentGatewayClient.failUrl(),
)
return orderRepository.saveAll(orders)
}
}
Loading

0 comments on commit ea96561

Please sign in to comment.