-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
1dfa139
commit ea96561
Showing
45 changed files
with
1,693 additions
and
212 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
src/main/kotlin/com/petqua/application/order/OrderProductsValidator.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
140
src/main/kotlin/com/petqua/application/order/OrderService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.