diff --git a/build.gradle b/build.gradle index e21db96e9..255a86574 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/org/c4marathon/assignment/domain/consumer/dto/request/PurchaseProductRequest.java b/src/main/java/org/c4marathon/assignment/domain/consumer/dto/request/PurchaseProductRequest.java index 735ced21b..9263b3ca4 100644 --- a/src/main/java/org/c4marathon/assignment/domain/consumer/dto/request/PurchaseProductRequest.java +++ b/src/main/java/org/c4marathon/assignment/domain/consumer/dto/request/PurchaseProductRequest.java @@ -8,6 +8,7 @@ public record PurchaseProductRequest( List<@Valid PurchaseProductEntry> purchaseProducts, @PositiveOrZero(message = "point less than 0") - long point + long point, + Long issuedCouponId ) { } diff --git a/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java b/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java index 11bedf863..cb6c71a7e 100644 --- a/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java +++ b/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java @@ -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.*; @@ -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; @@ -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 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 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; + } } /** @@ -85,6 +110,7 @@ public void refundOrder(Long orderId, Consumer consumer) { validateRefundRequest(consumer, order, delivery); updateStatusWhenRefund(order, delivery); + refundCoupon(order); savePointLog(consumer, order, false); } @@ -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 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에 대한 구매 횟수를 증가함 */ @@ -120,11 +216,12 @@ private void addOrderCount(List orderProducts) { } private void saveOrderInfo(PurchaseProductRequest request, Consumer consumer, Order order, - List orderProducts, long totalAmount) { + List 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()); } /** @@ -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); } diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java b/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java new file mode 100644 index 000000000..395dc1381 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java @@ -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); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/dto/request/CreateCouponRequest.java b/src/main/java/org/c4marathon/assignment/domain/coupon/dto/request/CreateCouponRequest.java new file mode 100644 index 000000000..b2621d9a5 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/dto/request/CreateCouponRequest.java @@ -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(); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java new file mode 100644 index 000000000..fcf68ed5b --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java @@ -0,0 +1,131 @@ +package org.c4marathon.assignment.domain.coupon.entity; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import java.time.LocalDateTime; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; +import org.c4marathon.assignment.global.constant.CouponType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "coupon_tbl" +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Coupon extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "coupon_id", columnDefinition = "BIGINT") + private Long id; + + @NotNull + @Column(name = "name", columnDefinition = "VARCHAR(20)", unique = true) + private String name; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "coupon_type", columnDefinition = "VARCHAR(20)", updatable = false) + private CouponType couponType; + + @NotNull + @Column(name = "redundant_usable", columnDefinition = "BIT", updatable = false) + private Boolean redundantUsable; + + @NotNull + @Column(name = "discount_policy_id", columnDefinition = "BIGINT") + private Long discountPolicyId; + + @NotNull + @Column(name = "event_id", columnDefinition = "BIGINT") + private Long eventId; + + @NotNull + @Column(name = "expired_time", columnDefinition = "DATETIME") + private LocalDateTime expiredTime; + + @Column(name = "maximum_usage", columnDefinition = "BIGINT default " + Long.MAX_VALUE) + private Long maximumUsage; + + @Column(name = "maximum_issued", columnDefinition = "BIGINT default " + Long.MAX_VALUE) + private Long maximumIssued; + + @NotNull + @Column(name = "used_count", columnDefinition = "BIGINT default 0") + private Long usedCount; + + @NotNull + @Column(name = "issued_count", columnDefinition = "BIGINT default 0") + private Long issuedCount; + + @Builder + public Coupon( + String name, + CouponType couponType, + Boolean redundantUsable, + Long discountPolicyId, + Long eventId, + LocalDateTime expiredTime, + Long maximumUsage, + Long maximumIssued + ) { + this.name = name; + this.couponType = couponType; + this.redundantUsable = redundantUsable; + this.discountPolicyId = discountPolicyId; + this.eventId = eventId; + this.expiredTime = expiredTime; + this.maximumUsage = maximumUsage; + this.maximumIssued = maximumIssued; + this.usedCount = 0L; + this.issuedCount = 0L; + } + + public void increaseIssuedCount() { + validateIssuedCount(); + issuedCount++; + } + + public void increaseUsedCount() { + validateUsedCount(); + usedCount++; + } + + public void decreaseUsedCount() { + usedCount--; + } + + public void validateTime() { + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(expiredTime)) { + throw RESOURCE_EXPIRED.baseException(); + } + } + + private void validateIssuedCount() { + if (issuedCount.equals(maximumIssued)) { + throw COUPON_NOT_ISSUABLE.baseException(); + } + } + + private void validateUsedCount() { + if (usedCount.equals(maximumUsage)) { + throw COUPON_NOT_USABLE.baseException(); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java new file mode 100644 index 000000000..f69b3326a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java @@ -0,0 +1,25 @@ +package org.c4marathon.assignment.domain.coupon.entity; + +import static java.util.Objects.*; + +import org.c4marathon.assignment.domain.coupon.dto.request.CreateCouponRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CouponFactory { + + public static Coupon buildCoupon(CreateCouponRequest request) { + return Coupon.builder() + .name(request.name()) + .couponType(request.couponType()) + .redundantUsable(request.redundantUsable()) + .discountPolicyId(request.discountPolicyId()) + .eventId(request.eventId()) + .expiredTime(request.expiredTime()) + .maximumUsage(requireNonNullElse(request.maximumUsage(), Long.MAX_VALUE)) + .maximumIssued(requireNonNullElse(request.maximumIssued(), Long.MAX_VALUE)) + .build(); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/FailedCouponLog.java b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/FailedCouponLog.java new file mode 100644 index 000000000..47675cd87 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/FailedCouponLog.java @@ -0,0 +1,38 @@ +package org.c4marathon.assignment.domain.coupon.entity; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "failed_coupon_log_tbl" +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@AllArgsConstructor +public class FailedCouponLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "failed_coupon_log_id", columnDefinition = "BIGINT") + private Long id; + + @NotNull + @Column(name = "coupon_id", columnDefinition = "BIGINT") + private Long couponId; + + @NotNull + @Column(name = "issued_coupon_id", columnDefinition = "BIGINT") + private Long issuedCouponId; +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/CouponRepository.java b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/CouponRepository.java new file mode 100644 index 000000000..749a75f0a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/CouponRepository.java @@ -0,0 +1,9 @@ +package org.c4marathon.assignment.domain.coupon.repository; + +import org.c4marathon.assignment.domain.coupon.entity.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java new file mode 100644 index 000000000..85df7e55d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -0,0 +1,17 @@ +package org.c4marathon.assignment.domain.coupon.repository; + +import java.util.Optional; + +import org.c4marathon.assignment.domain.coupon.entity.FailedCouponLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface FailedCouponLogRepository extends JpaRepository { + + @Query(value = """ + select * + from failed_coupon_log_tbl fcl + limit 1 + """, nativeQuery = true) + Optional findFirst(); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java new file mode 100644 index 000000000..f93f9f250 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java @@ -0,0 +1,28 @@ +package org.c4marathon.assignment.domain.coupon.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.coupon.entity.Coupon; +import org.c4marathon.assignment.domain.coupon.repository.CouponRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CouponReadService { + + private final CouponRepository couponRepository; + + @Transactional(readOnly = true) + public boolean existsByName(String name) { + return couponRepository.existsByName(name); + } + + @Transactional(readOnly = true) + public Coupon findById(Long id) { + return couponRepository.findById(id) + .orElseThrow(COUPON_NOT_FOUND::baseException); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java new file mode 100644 index 000000000..ce890bd12 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java @@ -0,0 +1,54 @@ +package org.c4marathon.assignment.domain.coupon.service; + +import static org.c4marathon.assignment.global.constant.CouponType.*; + +import org.c4marathon.assignment.domain.coupon.entity.Coupon; +import org.c4marathon.assignment.domain.coupon.entity.FailedCouponLog; +import org.c4marathon.assignment.domain.coupon.repository.FailedCouponLogRepository; +import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; +import org.c4marathon.assignment.domain.issuedcoupon.service.CouponRestrictionManager; +import org.c4marathon.assignment.domain.issuedcoupon.service.LockedCouponService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CouponRetryService { + + public static final int MAXIMUM_RETRY_COUNT = 3; + private final LockedCouponService lockedCouponService; + private final CouponRestrictionManager couponRestrictionManager; + private final FailedCouponLogRepository failedCouponLogRepository; + + /** + * lockedCouponService.increaseUsedCount가 실행된 이후에, 어떤 예외가 발생하면 아래 보상 로직이 실행됨. + * 보상 로직이기 때문에 무한대로 재시도 할 수는 없고.. + * 최대 3번 재시도 이후에 그래도 실패하면 FailedCouponLog로 남기게됨. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void decreaseUsedCount(IssuedCoupon issuedCoupon, Coupon coupon) { + if (issuedCoupon == null || coupon == null || coupon.getCouponType() != USE_COUPON) { + return; + } + int retryCount = 0; + while (true) { + try { + lockedCouponService.decreaseUsedCount(coupon.getId(), issuedCoupon.getId()); + couponRestrictionManager.removeNotUsableCoupon(coupon.getId()); + break; + } catch (Exception innerException) { + log.info("failed decrease coupon use count. retry count: {}", retryCount); + retryCount++; + if (retryCount >= MAXIMUM_RETRY_COUNT) { + failedCouponLogRepository.save(new FailedCouponLog(null, coupon.getId(), issuedCoupon.getId())); + throw innerException; + } + } + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java new file mode 100644 index 000000000..bf282620a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -0,0 +1,50 @@ +package org.c4marathon.assignment.domain.coupon.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.coupon.dto.request.CreateCouponRequest; +import org.c4marathon.assignment.domain.coupon.entity.CouponFactory; +import org.c4marathon.assignment.domain.coupon.repository.CouponRepository; +import org.c4marathon.assignment.domain.discountpolicy.service.DiscountPolicyReadService; +import org.c4marathon.assignment.domain.event.entity.Event; +import org.c4marathon.assignment.domain.event.service.EventReadService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CouponService { + + private final CouponRepository couponRepository; + private final DiscountPolicyReadService discountPolicyReadService; + private final EventReadService eventReadService; + private final CouponReadService couponReadService; + + @Transactional + public void createCoupon(CreateCouponRequest request) { + validateRequest(request); + couponRepository.save(CouponFactory.buildCoupon(request)); + } + + /** + * @throws org.c4marathon.assignment.global.error.BaseException + * 같은 이름의 쿠폰이 존재할 경우 + * 존재하지 않는 DiscountPolicy id를 요청할 경우 + * 쿠폰의 유효기간이 이벤트 유효기간보다 이후일 경우 + */ + private void validateRequest(CreateCouponRequest request) { + request.validate(); + if (couponReadService.existsByName(request.name())) { + throw ALREADY_COUPON_EXISTS.baseException(); + } + if (!discountPolicyReadService.existsById(request.discountPolicyId())) { + throw DISCOUNT_POLICY_NOT_FOUND.baseException(); + } + Event event = eventReadService.findById(request.eventId()); + if (event.getEndDate().isBefore(request.expiredTime())) { + throw INVALID_COUPON_EXPIRED_TIME.baseException(); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java new file mode 100644 index 000000000..745bc376d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java @@ -0,0 +1,27 @@ +package org.c4marathon.assignment.domain.discountpolicy.controller; + +import org.c4marathon.assignment.domain.discountpolicy.dto.request.DiscountPolicyRequest; +import org.c4marathon.assignment.domain.discountpolicy.service.DiscountPolicyService; +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("/discount-policy") +public class DiscountPolicyController { + + private final DiscountPolicyService discountPolicyService; + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public void createDiscountPolicy(@Valid @RequestBody DiscountPolicyRequest request) { + discountPolicyService.createDiscountPolicy(request); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/dto/request/DiscountPolicyRequest.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/dto/request/DiscountPolicyRequest.java new file mode 100644 index 000000000..eca83421d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/dto/request/DiscountPolicyRequest.java @@ -0,0 +1,34 @@ +package org.c4marathon.assignment.domain.discountpolicy.dto.request; + +import static org.c4marathon.assignment.global.constant.DiscountType.*; +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.global.constant.DiscountType; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record DiscountPolicyRequest( + @NotEmpty + @Size(max = 20) + String name, + @NotNull + DiscountType discountType, + Long discountAmount, + @Positive + @Max(100) + Integer discountRate +) { + + public void validate() { + if (discountType == FIXED_DISCOUNT && discountAmount == null) { + throw BIND_ERROR.baseException(); + } + if (discountType == RATED_DISCOUNT && discountRate == null) { + throw BIND_ERROR.baseException(); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java new file mode 100644 index 000000000..28ffce624 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java @@ -0,0 +1,57 @@ +package org.c4marathon.assignment.domain.discountpolicy.entity; + +import static org.c4marathon.assignment.global.constant.DiscountType.*; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; +import org.c4marathon.assignment.global.constant.DiscountType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "discount_policy_tbl" +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class DiscountPolicy extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "discount_policy_id", columnDefinition = "BIGINT") + private Long id; + + @NotNull + @Column(name = "name", columnDefinition = "VARCHAR(20)", unique = true) + private String name; + + @Enumerated(value = EnumType.STRING) + @NotNull + @Column(name = "discount_type", columnDefinition = "VARCHAR(20)") + private DiscountType discountType; + + @Column(name = "discount_amount", columnDefinition = "BIGINT") + private Long discountAmount; + + @Column(name = "discount_rate", columnDefinition = "INTEGER") + private Integer discountRate; + + public long calculateDiscountAmount(long totalAmount) { + if (discountType == RATED_DISCOUNT) { + return (long)Math.floor(totalAmount * ((double)getDiscountRate() / 100)); + } + return discountAmount; + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicyFactory.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicyFactory.java new file mode 100644 index 000000000..aa70e3022 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicyFactory.java @@ -0,0 +1,15 @@ +package org.c4marathon.assignment.domain.discountpolicy.entity; + +import org.c4marathon.assignment.domain.discountpolicy.dto.request.DiscountPolicyRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DiscountPolicyFactory { + + public static DiscountPolicy buildDiscountPolicy(DiscountPolicyRequest request) { + return new DiscountPolicy(null, request.name(), request.discountType(), request.discountAmount(), + request.discountRate()); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java new file mode 100644 index 000000000..36e7d0fff --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java @@ -0,0 +1,9 @@ +package org.c4marathon.assignment.domain.discountpolicy.repository; + +import org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicy; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DiscountPolicyRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java new file mode 100644 index 000000000..c25260625 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java @@ -0,0 +1,33 @@ +package org.c4marathon.assignment.domain.discountpolicy.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicy; +import org.c4marathon.assignment.domain.discountpolicy.repository.DiscountPolicyRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class DiscountPolicyReadService { + + private final DiscountPolicyRepository discountPolicyRepository; + + @Transactional(readOnly = true) + public boolean existsByName(String name) { + return discountPolicyRepository.existsByName(name); + } + + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return discountPolicyRepository.existsById(id); + } + + @Transactional(readOnly = true) + public DiscountPolicy findById(Long id) { + return discountPolicyRepository.findById(id) + .orElseThrow(DISCOUNT_POLICY_NOT_FOUND::baseException); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java new file mode 100644 index 000000000..2946d9bfb --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java @@ -0,0 +1,28 @@ +package org.c4marathon.assignment.domain.discountpolicy.service; + +import static org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicyFactory.*; +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.discountpolicy.dto.request.DiscountPolicyRequest; +import org.c4marathon.assignment.domain.discountpolicy.repository.DiscountPolicyRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class DiscountPolicyService { + + private final DiscountPolicyRepository discountPolicyRepository; + private final DiscountPolicyReadService discountPolicyReadService; + + @Transactional + public void createDiscountPolicy(DiscountPolicyRequest request) { + request.validate(); + if (discountPolicyReadService.existsByName(request.name())) { + throw ALREADY_DISCOUNT_POLICY_EXISTS.baseException(); + } + discountPolicyRepository.save(buildDiscountPolicy(request)); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/controller/EventController.java b/src/main/java/org/c4marathon/assignment/domain/event/controller/EventController.java new file mode 100644 index 000000000..812239d5a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/controller/EventController.java @@ -0,0 +1,27 @@ +package org.c4marathon.assignment.domain.event.controller; + +import org.c4marathon.assignment.domain.event.dto.request.PublishEventRequest; +import org.c4marathon.assignment.domain.event.service.EventService; +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("/events") +public class EventController { + + private final EventService eventService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void publishEvent(@Valid @RequestBody PublishEventRequest request) { + eventService.publishEvent(request); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/dto/request/PublishEventRequest.java b/src/main/java/org/c4marathon/assignment/domain/event/dto/request/PublishEventRequest.java new file mode 100644 index 000000000..a259d5626 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/dto/request/PublishEventRequest.java @@ -0,0 +1,18 @@ +package org.c4marathon.assignment.domain.event.dto.request; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PublishEventRequest( + @NotEmpty + @Size(max = 30) + String name, + @NotNull + @Future + LocalDateTime endDate +) { +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java b/src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java new file mode 100644 index 000000000..3443ecdaa --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java @@ -0,0 +1,40 @@ +package org.c4marathon.assignment.domain.event.entity; + +import java.time.LocalDateTime; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "event_tbl" +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Event extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_id", columnDefinition = "BIGINT", updatable = false) + private Long id; + + @NotNull + @Column(name = "name", columnDefinition = "VARCHAR(30)", unique = true) + private String name; + + @NotNull + @Column(name = "end_date", columnDefinition = "DATETIME") + private LocalDateTime endDate; +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/entity/EventFactory.java b/src/main/java/org/c4marathon/assignment/domain/event/entity/EventFactory.java new file mode 100644 index 000000000..c086dfe33 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/entity/EventFactory.java @@ -0,0 +1,14 @@ +package org.c4marathon.assignment.domain.event.entity; + +import org.c4marathon.assignment.domain.event.dto.request.PublishEventRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EventFactory { + + public static Event buildEvent(PublishEventRequest request) { + return new Event(null, request.name(), request.endDate()); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/repository/EventRepository.java b/src/main/java/org/c4marathon/assignment/domain/event/repository/EventRepository.java new file mode 100644 index 000000000..7e6f0dd52 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/repository/EventRepository.java @@ -0,0 +1,9 @@ +package org.c4marathon.assignment.domain.event.repository; + +import org.c4marathon.assignment.domain.event.entity.Event; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java new file mode 100644 index 000000000..7f10ce4d8 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java @@ -0,0 +1,33 @@ +package org.c4marathon.assignment.domain.event.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.event.entity.Event; +import org.c4marathon.assignment.domain.event.repository.EventRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class EventReadService { + + private final EventRepository eventRepository; + + @Transactional(readOnly = true) + public boolean existsByName(String name) { + return eventRepository.existsByName(name); + } + + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return eventRepository.existsById(id); + } + + @Transactional(readOnly = true) + public Event findById(Long id) { + return eventRepository.findById(id) + .orElseThrow(EVENT_NOT_FOUND::baseException); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/event/service/EventService.java b/src/main/java/org/c4marathon/assignment/domain/event/service/EventService.java new file mode 100644 index 000000000..f395037d0 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/service/EventService.java @@ -0,0 +1,29 @@ +package org.c4marathon.assignment.domain.event.service; + +import static org.c4marathon.assignment.domain.event.entity.EventFactory.*; + +import org.c4marathon.assignment.domain.event.dto.request.PublishEventRequest; +import org.c4marathon.assignment.domain.event.entity.Event; +import org.c4marathon.assignment.domain.event.repository.EventRepository; +import org.c4marathon.assignment.global.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class EventService { + + private final EventRepository eventRepository; + private final EventReadService eventReadService; + + @Transactional + public void publishEvent(PublishEventRequest request) { + if (eventReadService.existsByName(request.name())) { + throw ErrorCode.ALREADY_EVENT_EXISTS.baseException(); + } + Event event = buildEvent(request); + eventRepository.save(event); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java new file mode 100644 index 000000000..7fde20a1d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java @@ -0,0 +1,28 @@ +package org.c4marathon.assignment.domain.issuedcoupon.controller; + +import org.c4marathon.assignment.domain.issuedcoupon.dto.request.CouponIssueRequest; +import org.c4marathon.assignment.domain.issuedcoupon.service.IssuedCouponService; +import org.c4marathon.assignment.global.auth.ConsumerThreadLocal; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/issued-coupons") +public class IssuedCouponController { + + private final IssuedCouponService issuedCouponService; + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public long issueCoupon(@Valid @RequestBody CouponIssueRequest request) { + return issuedCouponService.issueCoupon(request, ConsumerThreadLocal.get()); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/dto/request/CouponIssueRequest.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/dto/request/CouponIssueRequest.java new file mode 100644 index 000000000..c42b7e5f0 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/dto/request/CouponIssueRequest.java @@ -0,0 +1,6 @@ +package org.c4marathon.assignment.domain.issuedcoupon.dto.request; + +public record CouponIssueRequest( + long couponId +) { +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java new file mode 100644 index 000000000..cf8600253 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java @@ -0,0 +1,64 @@ +package org.c4marathon.assignment.domain.issuedcoupon.entity; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "issued_coupon_tbl" +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class IssuedCoupon extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "issued_coupon_id", columnDefinition = "BIGINT") + private Long id; + + @NotNull + @Column(name = "coupon_id", columnDefinition = "BIGINT") + private Long couponId; + + @NotNull + @Column(name = "used_count", columnDefinition = "INTEGER") + private Integer usedCount; + + @NotNull + @Column(name = "consumer_id", columnDefinition = "BIGINT") + private Long consumerId; + + @Builder + public IssuedCoupon(Long couponId, Long consumerId) { + this.couponId = couponId; + this.usedCount = 0; + this.consumerId = consumerId; + } + + public void validatePermission(Long targetId) { + if (!consumerId.equals(targetId)) { + throw NO_PERMISSION.baseException(); + } + } + + public void increaseUsedCount() { + usedCount++; + } + + public void decreaseUsedCount() { + usedCount--; + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCouponFactory.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCouponFactory.java new file mode 100644 index 000000000..c2b9b3dc5 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCouponFactory.java @@ -0,0 +1,18 @@ +package org.c4marathon.assignment.domain.issuedcoupon.entity; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.c4marathon.assignment.domain.issuedcoupon.dto.request.CouponIssueRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class IssuedCouponFactory { + + public static IssuedCoupon buildIssuedCoupon(CouponIssueRequest request, Consumer consumer) { + return IssuedCoupon.builder() + .couponId(request.couponId()) + .consumerId(consumer.getId()) + .build(); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java new file mode 100644 index 000000000..a2cbc98f1 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -0,0 +1,41 @@ +package org.c4marathon.assignment.domain.issuedcoupon.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface IssuedCouponRepository extends JpaRepository { + + @Query(value = """ + select 1 + from issued_coupon_tbl ic + join coupon_tbl c on c.coupon_id = ic.coupon_id + where c.event_id = :eventId + and ic.consumer_id = :consumerId + limit 1 + """, + nativeQuery = true + ) + Long existsByConsumerIdCouponId_EventId(@Param("consumerId") Long consumerId, @Param("eventId") Long eventId); + + @Modifying + @Query(value = """ + delete + from issued_coupon_tbl ic + where ic.coupon_id = :couponId + """, nativeQuery = true) + void deleteByCouponId(@Param("couponId") Long couponId); + + @Query(value = """ + select c.coupon_id + from issued_coupon_tbl ic + join coupon_tbl c on c.coupon_id = ic.coupon_id + where c.expired_time < :now + """, nativeQuery = true) + List findExpiredCouponId(@Param("now") LocalDateTime now); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java new file mode 100644 index 000000000..27b76a3c3 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java @@ -0,0 +1,44 @@ +package org.c4marathon.assignment.domain.issuedcoupon.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import lombok.Getter; + +@Component +@Getter +public class CouponRestrictionManager { + + // 쿠폰의 수가 수십만개가 될 순 없고, 스케줄러로 돌면서 기간이 지난 쿠폰 또는 이벤트인 경우 + // 삭제할 것이기 때문에 캐시 라이브러리를 안쓰고 그냥 Map으로 함 + private final Set notIssuableCoupons = new HashSet<>(); + private final Set notUsableCoupons = new HashSet<>(); + + public void validateCouponIssuable(long couponId) { + if (notIssuableCoupons.contains(couponId)) { + throw COUPON_NOT_ISSUABLE.baseException(); + } + } + + public void addNotIssuableCoupon(long couponId) { + notIssuableCoupons.add(couponId); + } + + public void validateCouponUsable(long couponId) { + if (notUsableCoupons.contains(couponId)) { + throw COUPON_NOT_USABLE.baseException(); + } + } + + public void addNotUsableCoupon(long couponId) { + notUsableCoupons.add(couponId); + } + + public void removeNotUsableCoupon(long couponId) { + notUsableCoupons.remove(couponId); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java new file mode 100644 index 000000000..1eb4dcdbe --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java @@ -0,0 +1,25 @@ +package org.c4marathon.assignment.domain.issuedcoupon.service; + +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; +import org.c4marathon.assignment.domain.issuedcoupon.repository.IssuedCouponRepository; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class IssuedCouponReadService { + + private final IssuedCouponRepository issuedCouponRepository; + + public boolean existsByConsumerIdAndEventId(Long consumerId, Long eventId) { + return issuedCouponRepository.existsByConsumerIdCouponId_EventId(consumerId, eventId) != null; + } + + public IssuedCoupon findById(Long id) { + return issuedCouponRepository.findById(id) + .orElseThrow(ISSUED_COUPON_NOT_FOUND::baseException); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java new file mode 100644 index 000000000..7831e868c --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java @@ -0,0 +1,49 @@ +package org.c4marathon.assignment.domain.issuedcoupon.service; + +import static org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCouponFactory.*; +import static org.c4marathon.assignment.global.constant.CouponType.*; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.c4marathon.assignment.domain.coupon.entity.Coupon; +import org.c4marathon.assignment.domain.coupon.service.CouponReadService; +import org.c4marathon.assignment.domain.event.entity.Event; +import org.c4marathon.assignment.domain.event.service.EventReadService; +import org.c4marathon.assignment.domain.issuedcoupon.dto.request.CouponIssueRequest; +import org.c4marathon.assignment.domain.issuedcoupon.repository.IssuedCouponRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class IssuedCouponService { + + private final IssuedCouponRepository issuedCouponRepository; + private final CouponReadService couponReadService; + private final EventReadService eventReadService; + private final LockedCouponService lockedCouponService; + private final CouponRestrictionManager couponRestrictionManager; + + /** + * 쿠폰 발급 + * 하나의 이벤트에 여러개의 쿠폰이 생성될 수 있는데, 소비자는 하나의 이벤트에 하나의 쿠폰만 발급받을 수 있음 + * 선착순 발급 쿠폰일 때, 캐싱된 "이미 발급된 쿠폰 수"가 최대 발급 가능 수 이상이면(높을리는 없겠지만) 예외 터트림 + * 이걸로 일단 레디스 접근을 최소화하고.. + * 그게 아니라면 락 잡고 확인한 다음에 가능하면 쿠폰 발급함. + * 선착순 발급 쿠폰은 이벤트 하나 당 한 개의 쿠폰만 발급받을 수 있음 + * 선착순 사용 쿠폰은 이벤트 하나 당 여러개의 쿠폰을 발급받을 수 있음 + */ + @Transactional + public long issueCoupon(CouponIssueRequest request, Consumer consumer) { + Coupon coupon = couponReadService.findById(request.couponId()); + Event event = eventReadService.findById(coupon.getEventId()); + coupon.validateTime(); + + if (coupon.getCouponType() == ISSUE_COUPON) { + couponRestrictionManager.validateCouponIssuable(coupon.getId()); + return lockedCouponService.increaseIssueCount(event.getId(), request, consumer); + } + return issuedCouponRepository.save(buildIssuedCoupon(request, consumer)).getId(); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java new file mode 100644 index 000000000..063bbc26c --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java @@ -0,0 +1,75 @@ +package org.c4marathon.assignment.domain.issuedcoupon.service; + +import static org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCouponFactory.*; +import static org.c4marathon.assignment.global.error.ErrorCode.*; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.c4marathon.assignment.domain.coupon.entity.Coupon; +import org.c4marathon.assignment.domain.coupon.service.CouponReadService; +import org.c4marathon.assignment.domain.issuedcoupon.dto.request.CouponIssueRequest; +import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; +import org.c4marathon.assignment.domain.issuedcoupon.repository.IssuedCouponRepository; +import org.c4marathon.assignment.global.aop.annotation.CouponIssueLock; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LockedCouponService { + + private final CouponReadService couponReadService; + private final IssuedCouponReadService issuedCouponReadService; + private final CouponRestrictionManager couponRestrictionManager; + private final IssuedCouponRepository issuedCouponRepository; + + /** + * 선착순 발급 쿠폰에서 발급 시에 issuedCount를 증가시키는 로직 + * 선착순 발급 쿠폰은 "한 이벤트에서 하나의 쿠폰 종류만 발급" 받을 수 있음. + * 그래서 만약 maximumUsage == usedCount 이면, 다음 요청이 굳이 레디스 락 걸고 이 메서드를 실행시키지 않아도 되므로 + * couponRestrictionManager에 캐싱함 + */ + @CouponIssueLock(key = "#eventId") + public long increaseIssueCount(Long eventId, CouponIssueRequest request, Consumer consumer) { + validateRedundantIssue(eventId, consumer.getId()); + Coupon coupon = couponReadService.findById(request.couponId()); + if (coupon.getIssuedCount().equals(coupon.getMaximumIssued())) { + couponRestrictionManager.addNotIssuableCoupon(coupon.getId()); + } + coupon.increaseIssuedCount(); + return issuedCouponRepository.save(buildIssuedCoupon(request, consumer)).getId(); + } + + /** + * 선착순 사용 쿠폰에서 사용 시에 usedCount를 증가시키는 로직 + * 이것도 마찬가지로 couponRestrictionManager에 캐싱함 + */ + @CouponIssueLock(key = "#couponId") + public void increaseUsedCount(Long couponId, Long issuedCouponId) { + Coupon coupon = couponReadService.findById(couponId); + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); + if (coupon.getUsedCount().equals(coupon.getMaximumUsage())) { + couponRestrictionManager.addNotUsableCoupon(coupon.getId()); + } + coupon.increaseUsedCount(); + issuedCoupon.increaseUsedCount(); + } + + /** + * 쿠폰 사용 로직 이후, 예외가 발생했을 때 쿠폰 사용 로직은 다른 트랜잭션에서 진행됐으므로 수행해야 하는 보상 로직 + * 쿠폰의 usedCount를 감소 + */ + @CouponIssueLock(key = "#couponId") + public void decreaseUsedCount(Long couponId, Long issuedCouponId) { + Coupon coupon = couponReadService.findById(couponId); + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); + coupon.decreaseUsedCount(); + issuedCoupon.decreaseUsedCount(); + } + + private void validateRedundantIssue(Long eventId, Long consumerId) { + if (issuedCouponReadService.existsByConsumerIdAndEventId(consumerId, eventId)) { + throw SINGLE_COUPON_AVAILABLE_PER_EVENT.baseException(); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/order/entity/Order.java b/src/main/java/org/c4marathon/assignment/domain/order/entity/Order.java index 241b27412..ce352976c 100644 --- a/src/main/java/org/c4marathon/assignment/domain/order/entity/Order.java +++ b/src/main/java/org/c4marathon/assignment/domain/order/entity/Order.java @@ -53,6 +53,9 @@ public class Order extends BaseEntity { @Column(name = "delivery_id", columnDefinition = "BIGINT") private Long deliveryId; + @Column(name = "issued_coupon_id", columnDefinition = "BIGINT") + private Long issuedCouponId; + @Builder public Order(OrderStatus orderStatus, Consumer consumer, long usedPoint) { this.orderStatus = orderStatus; @@ -75,4 +78,8 @@ public void updateEarnedPoint(long earnedPoint) { public void updateTotalAmount(long totalAmount) { this.totalAmount = totalAmount; } + + public void updateIssuedCouponId(Long issuedCouponId) { + this.issuedCouponId = issuedCouponId; + } } diff --git a/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java new file mode 100644 index 000000000..e5df60969 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java @@ -0,0 +1,58 @@ +package org.c4marathon.assignment.global.aop; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.c4marathon.assignment.global.aop.annotation.CouponIssueLock; +import org.c4marathon.assignment.global.aop.annotation.TransactionAop; +import org.c4marathon.assignment.global.common.CouponElParser; +import org.c4marathon.assignment.global.error.ErrorCode; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Aspect +@Component +@RequiredArgsConstructor +public class CouponIssueLockAop { + + private static final String COUPON_ISSUE_LOCK_PREFIX = "EVENT_LOCK:"; + private final RedissonClient redissonClient; + private final TransactionAop transactionAop; + + /** + * redis distributed lock, unlock을 진행하는 AOP + * 파라미터를 통해서 lock key를 정하고 redisson client의 tryLock 이후에 unlock + */ + @Around("@annotation(org.c4marathon.assignment.global.aop.annotation.CouponIssueLock)") + public Object couponIssueLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Method method = signature.getMethod(); + CouponIssueLock couponIssueLock = method.getAnnotation(CouponIssueLock.class); + + String key = COUPON_ISSUE_LOCK_PREFIX + CouponElParser.getDynamicValue( + signature.getParameterNames(), + joinPoint.getArgs(), + couponIssueLock.key()); + RLock lock = redissonClient.getLock(key); + + try { + if (!lock.tryLock(couponIssueLock.waitTime(), couponIssueLock.leaseTime(), TimeUnit.SECONDS)) { + return false; + } + return transactionAop.proceed(joinPoint); + } catch (InterruptedException e) { + throw ErrorCode.SERVER_ERROR.baseException(); + } finally { + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java b/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java new file mode 100644 index 000000000..740f16111 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java @@ -0,0 +1,17 @@ +package org.c4marathon.assignment.global.aop.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CouponIssueLock { + + String key() default ""; + + long waitTime() default 5; + + long leaseTime() default 1; +} diff --git a/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java b/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java new file mode 100644 index 000000000..472f35a4f --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java @@ -0,0 +1,20 @@ +package org.c4marathon.assignment.global.aop.annotation; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class TransactionAop { + + /** + * distributed lock 이후에 새로운 트랜잭션으로 감싸는 AOP + * REQUIRES_NEW가 아닌 REQUIRES(default)로 할 경우, 커밋 되지 않은 시점에서 다른 트랜잭션이 락을 얻을 수 있기 때문에 + * 데이터 정합성에서 문제가 발생할 수 있음 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/common/CouponElParser.java b/src/main/java/org/c4marathon/assignment/global/common/CouponElParser.java new file mode 100644 index 000000000..418989d70 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/common/CouponElParser.java @@ -0,0 +1,21 @@ +package org.c4marathon.assignment.global.common; + +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class CouponElParser { + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + SpelExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/configuration/RedissonConfiguration.java b/src/main/java/org/c4marathon/assignment/global/configuration/RedissonConfiguration.java new file mode 100644 index 000000000..cab4e687f --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/configuration/RedissonConfiguration.java @@ -0,0 +1,26 @@ +package org.c4marathon.assignment.global.configuration; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfiguration { + + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port); + return Redisson.create(config); + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java index 36994cfff..084770465 100644 --- a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java +++ b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java @@ -1,5 +1,6 @@ package org.c4marathon.assignment.global.configuration; +import org.c4marathon.assignment.global.interceptor.AdminInterceptor; import org.c4marathon.assignment.global.interceptor.ConsumerInterceptor; import org.c4marathon.assignment.global.interceptor.DeliveryCompanyInterceptor; import org.c4marathon.assignment.global.interceptor.SellerInterceptor; @@ -16,12 +17,13 @@ public class WebConfiguration implements WebMvcConfigurer { private final ConsumerInterceptor consumerInterceptor; private final SellerInterceptor sellerInterceptor; private final DeliveryCompanyInterceptor deliveryCompanyInterceptor; + private final AdminInterceptor adminInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry .addInterceptor(consumerInterceptor) - .addPathPatterns("/consumers/**", "/reviews/**"); + .addPathPatterns("/consumers/**", "/reviews/**", "/issued-coupons/**"); registry .addInterceptor(sellerInterceptor) @@ -30,5 +32,8 @@ public void addInterceptors(InterceptorRegistry registry) { registry .addInterceptor(deliveryCompanyInterceptor) .addPathPatterns("/orders/deliveries/**"); + registry + .addInterceptor(adminInterceptor) + .addPathPatterns("/event/**", "/discount-policy/**", "/coupons/**"); } } diff --git a/src/main/java/org/c4marathon/assignment/global/constant/CouponType.java b/src/main/java/org/c4marathon/assignment/global/constant/CouponType.java new file mode 100644 index 000000000..76800deb3 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/constant/CouponType.java @@ -0,0 +1,12 @@ +package org.c4marathon.assignment.global.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum CouponType { + + USE_COUPON, + ISSUE_COUPON +} diff --git a/src/main/java/org/c4marathon/assignment/global/constant/DiscountType.java b/src/main/java/org/c4marathon/assignment/global/constant/DiscountType.java new file mode 100644 index 000000000..4011b4215 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/constant/DiscountType.java @@ -0,0 +1,12 @@ +package org.c4marathon.assignment.global.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum DiscountType { + + FIXED_DISCOUNT, + RATED_DISCOUNT +} diff --git a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java index 2e009f68e..51605a87f 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -31,7 +31,23 @@ public enum ErrorCode { NOT_ENOUGH_POINT(BAD_REQUEST, "사용할 포인트가 부족합니다."), CONSUMER_NOT_FOUND_BY_ID(NOT_FOUND, "id에 해당하는 Consumer가 존재하지 않습니다."), REVIEW_ALREADY_EXISTS(CONFLICT, "해당 product에 대한 review가 이미 존재합니다."), - NOT_POSSIBLE_CREATE_REVIEW(NOT_FOUND, "해당 product에 대한 구매 이력이 존재하지 않거나, 리뷰 작성 가능 기간이 지났습니다."); + NOT_POSSIBLE_CREATE_REVIEW(NOT_FOUND, "해당 product에 대한 구매 이력이 존재하지 않거나, 리뷰 작성 가능 기간이 지났습니다."), + ALREADY_EVENT_EXISTS(CONFLICT, "해당 name에 대한 event가 이미 존재합니다."), + ALREADY_DISCOUNT_POLICY_EXISTS(CONFLICT, "해당 name에 대한 discount_policy가 이미 존재합니다."), + DISCOUNT_POLICY_NOT_FOUND(NOT_FOUND, "요청에 해당하는 discount_policy가 존재하지 않습니다."), + EVENT_NOT_FOUND(NOT_FOUND, "요청에 해당하는 event가 존재하지 않습니다."), + ALREADY_COUPON_EXISTS(CONFLICT, "해당 name에 대한 coupon이 이미 존재합니다."), + COUPON_NOT_FOUND(NOT_FOUND, "요청에 해당하는 쿠폰 종류가 존재하지 않습니다."), + RESOURCE_EXPIRED(GONE, "더 이상 사용할 수 없는 자원입니다."), + COUPON_NOT_ISSUABLE(BAD_REQUEST, "더 이상 해당 쿠폰을 발급할 수 없습니다."), + SINGLE_COUPON_AVAILABLE_PER_EVENT(CONFLICT, "하나의 이벤트에 하나의 쿠폰만 발급 가능합니다."), + ISSUED_COUPON_NOT_FOUND(NOT_FOUND, "요청에 해당하는 발급된 쿠폰이 존재하지 않습니다."), + INVALID_COUPON_EXPIRED_TIME(BAD_REQUEST, "쿠폰 유효기간은 이벤트 기간 이후일 수 없습니다."), + ALREADY_USED_COUPON(BAD_REQUEST, "이미 사용된 쿠폰입니다."), + COUPON_NOT_USABLE(BAD_REQUEST, "더 이상 해당 쿠폰을 사용할 수 없습니다."), + EXCESSIVE_POINT_USE(BAD_REQUEST, "사용하려는 포인트가 주문 금액보다 많습니다."), + + SERVER_ERROR(INTERNAL_SERVER_ERROR, "request was interrupted. please try again."); private final HttpStatus status; private final String message; diff --git a/src/main/java/org/c4marathon/assignment/global/interceptor/AdminInterceptor.java b/src/main/java/org/c4marathon/assignment/global/interceptor/AdminInterceptor.java new file mode 100644 index 000000000..a443d4ffa --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/interceptor/AdminInterceptor.java @@ -0,0 +1,20 @@ +package org.c4marathon.assignment.global.interceptor; + +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + Optional email = Optional.ofNullable(request.getHeader("Authorization")); + return email.isPresent() && StringUtils.equals(email.get(), "admin"); + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java new file mode 100644 index 000000000..ac474402d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java @@ -0,0 +1,34 @@ +package org.c4marathon.assignment.global.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.c4marathon.assignment.domain.issuedcoupon.repository.IssuedCouponRepository; +import org.c4marathon.assignment.domain.issuedcoupon.service.CouponRestrictionManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CouponWithdrawScheduler { + + private final CouponRestrictionManager couponRestrictionManager; + private final IssuedCouponRepository issuedCouponRepository; + + /** + * 쿠폰 유효기간이 지난 것을 삭제 + * 유효기간이 지난 쿠폰을 확인하면서, couponRestrictionManager에 캐싱된 데이터도 함께 삭제 + */ + @Scheduled(fixedDelay = 60 * 1_000) + @Transactional + public void scheduleCouponWithdraw() { + List expiredCouponIds = issuedCouponRepository.findExpiredCouponId(LocalDateTime.now()); + for (Long expiredCouponId : expiredCouponIds) { + couponRestrictionManager.removeNotUsableCoupon(expiredCouponId); + issuedCouponRepository.deleteByCouponId(expiredCouponId); + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java b/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java new file mode 100644 index 000000000..77eff2338 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java @@ -0,0 +1,46 @@ +package org.c4marathon.assignment.global.scheduler; + +import java.util.Optional; + +import org.c4marathon.assignment.domain.coupon.entity.FailedCouponLog; +import org.c4marathon.assignment.domain.coupon.repository.FailedCouponLogRepository; +import org.c4marathon.assignment.domain.issuedcoupon.service.LockedCouponService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class FailedCouponScheduler { + + private final FailedCouponLogRepository failedCouponLogRepository; + private final LockedCouponService lockedCouponService; + + /** + * 쿠폰 사용 시 실패했는데, 재시도 3번 조차 실패한 쿠폰 사용 로직에 대한 처리를 맡는 스케줄러 + * 5초마다 돌게 되는데, 계속 실패하는 거 때문에 무한 루프 타지 않도록 큐처럼 선입 선출로 처리함. + */ + @Scheduled(fixedDelay = 5000) + @Transactional + public void scheduleFailedCouponLog() { + long count = failedCouponLogRepository.count(); + while (count-- > 0) { + Optional failedCouponLogOptional = failedCouponLogRepository.findFirst(); + if (failedCouponLogOptional.isEmpty()) { + break; + } + FailedCouponLog failedCouponLog = failedCouponLogOptional.get(); + try { + lockedCouponService.decreaseUsedCount(failedCouponLog.getIssuedCouponId(), + failedCouponLog.getCouponId()); + } catch (Exception e) { + failedCouponLogRepository.save(new FailedCouponLog(null, failedCouponLog.getIssuedCouponId(), + failedCouponLog.getCouponId())); + } finally { + failedCouponLogRepository.delete(failedCouponLog); + } + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 262c9dfaf..6ecf6923b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,11 @@ spring: maximum-pool-size: 10 url: jdbc:mysql://localhost:3306/community?rewriteBatchedStatements=true + data: + redis: + host: localhost + port: 6379 + mybatis: mapper-locations: classpath:mapper/*.xml