Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/kariskan step4 #15

Open
wants to merge 33 commits into
base: base/kariskan
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6f68200
feat: 필요 엔티티 작성
kariskan Jun 12, 2024
1125229
feat: 이벤트 생성 로직 추가
kariskan Jun 12, 2024
94f75a1
fix: name unique constraint 추가
kariskan Jun 12, 2024
cbc09bb
feat: DiscountPolicy 추가 로직 작성
kariskan Jun 12, 2024
a208887
feat: Coupon 추가 로직 작성
kariskan Jun 13, 2024
ecb4407
fix: coupon 생성 validation 추가
kariskan Jun 13, 2024
a5e0b27
fix: column definition 변경
kariskan Jun 17, 2024
27a4b9d
feat: redisson configuration
kariskan Jun 20, 2024
5c388a0
fix: requireNonNullElse 추가
kariskan Jun 20, 2024
474946e
feat: 쿠폰 발급
kariskan Jun 21, 2024
39a3917
feat: 쿠폰 사용
kariskan Jun 21, 2024
77d6342
fix: @Future 추가
kariskan Jun 21, 2024
c8b1f69
refactor: coupon 유효기간 변수명 변경
kariskan Jun 21, 2024
05715fb
fix: exists 쿼리 개선
kariskan Jun 22, 2024
dfd1110
fix: decreaseUsedCount() 추가
kariskan Jun 22, 2024
b52dcc8
refactor: 메서드 순서 변경
kariskan Jun 22, 2024
8b9f91b
refactor: 객체 내부에서 검증
kariskan Jun 22, 2024
1ac9755
style: code reformat
kariskan Jun 22, 2024
ea4e478
feat: cache eviction 구현
kariskan Jun 22, 2024
2fc4632
feat: decrease use count 및 scheduler 작성
kariskan Jun 22, 2024
f73e636
fix: order by 제거
kariskan Jun 22, 2024
aa9e2ec
feat: RuntimeException 제거
kariskan Jun 22, 2024
3fed571
feat: 선착순 사용 쿠폰 회수
kariskan Jun 22, 2024
69d9db8
refactor: reverse if-else
kariskan Jun 22, 2024
e5d6c43
fix: http status 추가
kariskan Jun 22, 2024
0f1c3b5
fix: fixedRate -> fixedDelay로 수정
kariskan Jun 22, 2024
f9d9fe5
refactor: 불필요한 어노테이션 삭제
kariskan Jun 22, 2024
b4c9fa7
refactor: magic number 제거
kariskan Jun 22, 2024
ef83c70
style: code reformat
kariskan Jun 22, 2024
87f39ea
docs: 주석 추가
kariskan Jun 22, 2024
bd1d371
fix: 메서드 파라미터 수정
kariskan Jun 22, 2024
3047b0a
fix: @Param 추가
kariskan Jun 22, 2024
edfde15
style: indent 수정
kariskan Jul 1, 2024
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
runtimeOnly 'com.h2database:h2'
implementation 'org.redisson:redisson:3.31.0'

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
public record PurchaseProductRequest(
List<@Valid PurchaseProductEntry> purchaseProducts,
@PositiveOrZero(message = "point less than 0")
long point
long point,
Long issuedCouponId
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.c4marathon.assignment.domain.consumer.service;

import static org.c4marathon.assignment.global.constant.CouponType.*;
import static org.c4marathon.assignment.global.constant.DeliveryStatus.*;
import static org.c4marathon.assignment.global.constant.OrderStatus.*;
import static org.c4marathon.assignment.global.error.ErrorCode.*;
Expand All @@ -13,10 +14,19 @@
import org.c4marathon.assignment.domain.consumer.dto.request.PurchaseProductRequest;
import org.c4marathon.assignment.domain.consumer.entity.Consumer;
import org.c4marathon.assignment.domain.consumer.repository.ConsumerRepository;
import org.c4marathon.assignment.domain.coupon.entity.Coupon;
import org.c4marathon.assignment.domain.coupon.service.CouponReadService;
import org.c4marathon.assignment.domain.coupon.service.CouponRetryService;
import org.c4marathon.assignment.domain.delivery.entity.Delivery;
import org.c4marathon.assignment.domain.delivery.repository.DeliveryRepository;
import org.c4marathon.assignment.domain.delivery.service.DeliveryReadService;
import org.c4marathon.assignment.domain.deliverycompany.service.DeliveryCompanyReadService;
import org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicy;
import org.c4marathon.assignment.domain.discountpolicy.service.DiscountPolicyReadService;
import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon;
import org.c4marathon.assignment.domain.issuedcoupon.service.CouponRestrictionManager;
import org.c4marathon.assignment.domain.issuedcoupon.service.IssuedCouponReadService;
import org.c4marathon.assignment.domain.issuedcoupon.service.LockedCouponService;
import org.c4marathon.assignment.domain.order.entity.Order;
import org.c4marathon.assignment.domain.order.repository.OrderRepository;
import org.c4marathon.assignment.domain.order.service.OrderReadService;
Expand Down Expand Up @@ -51,23 +61,38 @@ public class ConsumerService {
private final DeliveryCompanyReadService deliveryCompanyReadService;
private final PointLogRepository pointLogRepository;
private final DeliveryReadService deliveryReadService;
private final IssuedCouponReadService issuedCouponReadService;
private final CouponReadService couponReadService;
private final LockedCouponService lockedCouponService;
private final DiscountPolicyReadService discountPolicyReadService;
private final CouponRestrictionManager couponRestrictionManager;
private final CouponRetryService couponRetryService;

/**
* 상품 구매
* 최종 결제 금액 = 총 구입 금액 - 사용할 포인트
* 최종 결제 금액 = (총 구입 금액 - 할인된 금액) - 사용할 포인트
* 이후 구매 확정 단계에서 사용하기 위해 Order Entity에 포인트 관련 필드를 추가
* 선착순 사용 쿠폰일 경우에, 주문을 하고 환불을 해도 쿠폰은 환불되지 않음.
* 포인트 같은 경우에는 모든 할인(총 주문 금액 - 쿠폰 할인 - 포인트 사용 금액)이 적용된 최종 결제 금액에 5퍼센트가 적용됨.
* @param consumer 상품 구매하는 소비자
*/
@Transactional
public void purchaseProduct(PurchaseProductRequest request, Consumer consumer) {
decreasePoint(consumer, request.point());
Order order = saveOrder(consumer, request.point());
List<OrderProduct> orderProducts = getOrderProducts(request, order);
long totalAmount = orderProducts.stream()
.mapToLong(OrderProduct::getAmount)
.sum();
decreaseBalance(consumer, totalAmount - request.point());
saveOrderInfo(request, consumer, order, orderProducts, totalAmount);
IssuedCoupon issuedCoupon = getIssuedCoupon(request.issuedCouponId(), consumer);
Coupon coupon = useCoupon(issuedCoupon);

try {
decreasePoint(consumer, request.point());
Order order = saveOrder(consumer, request.point());
List<OrderProduct> orderProducts = getOrderProducts(request, order);
long totalAmount = calculateTotalAmount(orderProducts, coupon);
decreaseBalance(consumer, totalAmount - request.point());
saveOrderInfo(request, consumer, order, orderProducts, totalAmount, issuedCoupon);
} catch (Exception e) {
// 예외가 발생했을때 선착순 사용 쿠폰을 원상복구 해야함
couponRetryService.decreaseUsedCount(issuedCoupon, coupon);
throw e;
}
}

/**
Expand All @@ -85,6 +110,7 @@ public void refundOrder(Long orderId, Consumer consumer) {
validateRefundRequest(consumer, order, delivery);

updateStatusWhenRefund(order, delivery);
refundCoupon(order);
savePointLog(consumer, order, false);
}

Expand Down Expand Up @@ -112,6 +138,76 @@ public void confirmOrder(Long orderId, Consumer consumer) {
savePointLog(consumer, order, true);
}

private IssuedCoupon getIssuedCoupon(Long issuedCouponId, Consumer consumer) {
if (issuedCouponId == null) {
return null;
}
IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId);
issuedCoupon.validatePermission(consumer.getId());
return issuedCoupon;
}

/**
* 쿠폰 사용
* 선착순 발급 쿠폰인 경우는 사용만 하면 되기 때문에 따로 락 메커니즘은 필요없음, 중복 사용도 가능함
* 선착순 사용 쿠폰인 경우는 락 메커니즘이 필요함.
* 사용 횟수 다다르면 couponRestrictionManager에 캐싱해둠
* 그래서 분산락 얻기 전에 캐싱된 데이터 보고 레디스 접근을 최소화함.
* @throws org.c4marathon.assignment.global.error.BaseException
* 내가 발급받은 쿠폰이 아닌 경우
* 기간이 지난 쿠폰인 경우
* 중복 불가능 쿠폰인데 이미 사용된 경우
*/
private Coupon useCoupon(IssuedCoupon issuedCoupon) {
if (issuedCoupon == null) {
return null;
}
Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId());
coupon.validateTime();
validateRedundant(coupon, issuedCoupon);
if (coupon.getCouponType() == USE_COUPON) {
couponRestrictionManager.validateCouponUsable(coupon.getId());
lockedCouponService.increaseUsedCount(coupon.getId(), issuedCoupon.getId());
} else {
issuedCoupon.increaseUsedCount();
}
return coupon;
}

/**
* 쿠폰이 중복 사용 불가능하고 이미 사용된 경우, 예외를 반환함
*/
private void validateRedundant(Coupon coupon, IssuedCoupon issuedCoupon) {
if (!coupon.getRedundantUsable() && issuedCoupon.getUsedCount() > 0) {
throw ALREADY_USED_COUPON.baseException();
}
}

private long calculateTotalAmount(List<OrderProduct> orderProducts, Coupon coupon) {
long totalAmount = orderProducts.stream()
.mapToLong(OrderProduct::getAmount)
.sum();
if (coupon != null) {
DiscountPolicy discountPolicy = discountPolicyReadService.findById(coupon.getDiscountPolicyId());
totalAmount = Math.max(0, totalAmount - discountPolicy.calculateDiscountAmount(totalAmount));
}
return totalAmount;
}

/**
* 환불은 선착순 발급 쿠폰만 가능하기 때문에 동시성 제어를 딱히 할 필요가 없음
*/
private void refundCoupon(Order order) {
if (order.getIssuedCouponId() == null) {
return;
}
IssuedCoupon issuedCoupon = issuedCouponReadService.findById(order.getIssuedCouponId());
Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId());
if (coupon.getCouponType() != USE_COUPON) {
issuedCoupon.decreaseUsedCount();
}
}

/**
* 주문 시 product에 대한 구매 횟수를 증가함
*/
Expand All @@ -120,11 +216,12 @@ private void addOrderCount(List<OrderProduct> orderProducts) {
}

private void saveOrderInfo(PurchaseProductRequest request, Consumer consumer, Order order,
List<OrderProduct> orderProducts, long totalAmount) {
List<OrderProduct> orderProducts, long totalAmount, IssuedCoupon issuedCoupon) {
orderProductJdbcRepository.saveAllBatch(orderProducts);
order.updateEarnedPoint(getPurchasePoint(totalAmount - request.point()));
order.updateTotalAmount(totalAmount);
order.updateDeliveryId(saveDelivery(consumer).getId());
order.updateIssuedCouponId(issuedCoupon == null ? null : issuedCoupon.getId());
}

/**
Expand Down Expand Up @@ -229,6 +326,9 @@ private void decreaseStock(PurchaseProductEntry purchaseProductEntry, Product pr
* 잔고가 부족할 시 예외를 반환하고, 아니면 잔고를 감소
*/
private void decreaseBalance(Consumer consumer, long totalAmount) {
if (totalAmount < 0) {
throw EXCESSIVE_POINT_USE.baseException();
}
if (consumer.getBalance() < totalAmount) {
throw NOT_ENOUGH_BALANCE.baseException("total amount: %d", totalAmount);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.c4marathon.assignment.domain.coupon.controller;

import org.c4marathon.assignment.domain.coupon.dto.request.CreateCouponRequest;
import org.c4marathon.assignment.domain.coupon.service.CouponService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/coupons")
public class CouponController {

private final CouponService couponService;

@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public void createCoupon(@Valid @RequestBody CreateCouponRequest request) {
couponService.createCoupon(request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.c4marathon.assignment.domain.coupon.dto.request;

import static org.c4marathon.assignment.global.error.ErrorCode.*;

import java.time.LocalDateTime;

import org.c4marathon.assignment.global.constant.CouponType;

import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record CreateCouponRequest(
@NotEmpty
@Size(max = 20)
String name,
@NotNull
CouponType couponType,
boolean redundantUsable,
long discountPolicyId,
long eventId,
@NotNull
@Future
LocalDateTime expiredTime,
Long maximumUsage,
Long maximumIssued
) {

/**
* 선착순 사용쿠폰인 경우 maximumUsage가 null이면 안됨
* 선착순 발급쿠폰인 경우 maximumIssued가 null이면 안됨
*/
public void validate() {
if (couponType == CouponType.USE_COUPON && maximumUsage == null) {
throw BIND_ERROR.baseException();
}
if (couponType == CouponType.ISSUE_COUPON && maximumIssued == null) {
throw BIND_ERROR.baseException();
}
}
}
Loading
Loading