From 6f68200f9f9d08c1c4abfaad7372f7d3d18dd8b4 Mon Sep 17 00:00:00 2001 From: kariskan Date: Wed, 12 Jun 2024 17:02:18 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat:=20=ED=95=84=EC=9A=94=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/coupon/entity/Coupon.java | 102 ++++++++++++++++++ .../discountpolicy/entity/DiscountPolicy.java | 44 ++++++++ .../assignment/domain/event/entity/Event.java | 40 +++++++ .../issuedcoupon/entity/IssuedCoupon.java | 40 +++++++ .../global/constant/CouponType.java | 12 +++ .../global/constant/DiscountType.java | 12 +++ 6 files changed, 250 insertions(+) create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java create mode 100644 src/main/java/org/c4marathon/assignment/global/constant/CouponType.java create mode 100644 src/main/java/org/c4marathon/assignment/global/constant/DiscountType.java 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..fb3c3f91b --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java @@ -0,0 +1,102 @@ +package org.c4marathon.assignment.domain.coupon.entity; + +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)") + 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 = "serial_number", columnDefinition = "VARCHAR(30)", updatable = false) + private String serialNumber; + + @NotNull + @Column(name = "event_id", columnDefinition = "BIGINT") + private Long eventId; + + @NotNull + @Column(name = "validity", columnDefinition = "DATETIME") + private LocalDateTime validity; + + @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 = "use_count", columnDefinition = "BIGINT default 0") + private Long useCount; + + @NotNull + @Column(name = "issued_count", columnDefinition = "BIGINT default 0") + private Long issuedCount; + + @Builder + public Coupon( + String name, + CouponType couponType, + Boolean redundantUsable, + Long discountPolicyId, + String serialNumber, + Long eventId, + LocalDateTime validity, + Long maximumUsage, + Long maximumIssued + ) { + this.name = name; + this.couponType = couponType; + this.redundantUsable = redundantUsable; + this.discountPolicyId = discountPolicyId; + this.serialNumber = serialNumber; + this.eventId = eventId; + this.validity = validity; + this.maximumUsage = maximumUsage; + this.maximumIssued = maximumIssued; + this.useCount = 0L; + this.issuedCount = 0L; + } +} 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..ed587b09c --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java @@ -0,0 +1,44 @@ +package org.c4marathon.assignment.domain.discountpolicy.entity; + +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; + + @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; +} 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..40d66c3b4 --- /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)") + private String name; + + @NotNull + @Column(name = "end_date", columnDefinition = "DATETIME") + private LocalDateTime endDate; +} 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..7d71462b8 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java @@ -0,0 +1,40 @@ +package org.c4marathon.assignment.domain.issuedcoupon.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.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 = "is_used", columnDefinition = "BIT default 0") + private Boolean isUsed; + + @NotNull + @Column(name = "consumer_id", columnDefinition = "BIGINT") + private Long consumerId; +} 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 +} From 11252295c5b4537e633139614ed067aaaaf37245 Mon Sep 17 00:00:00 2001 From: kariskan Date: Wed, 12 Jun 2024 17:27:00 +0900 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - event publish 관련 코드는 admin만 접근 가능 - name duplicate validation --- .../event/controller/EventController.java | 27 +++++++++++++++++ .../dto/request/PublishEventRequest.java | 16 ++++++++++ .../domain/event/entity/EventFactory.java | 14 +++++++++ .../event/repository/EventRepository.java | 9 ++++++ .../event/service/EventReadService.java | 19 ++++++++++++ .../domain/event/service/EventService.java | 29 +++++++++++++++++++ .../configuration/WebConfiguration.java | 5 ++++ .../assignment/global/error/ErrorCode.java | 3 +- .../global/interceptor/AdminInterceptor.java | 20 +++++++++++++ 9 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/controller/EventController.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/dto/request/PublishEventRequest.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/entity/EventFactory.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/repository/EventRepository.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/event/service/EventService.java create mode 100644 src/main/java/org/c4marathon/assignment/global/interceptor/AdminInterceptor.java 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..802173777 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/dto/request/PublishEventRequest.java @@ -0,0 +1,16 @@ +package org.c4marathon.assignment.domain.event.dto.request; + +import java.time.LocalDateTime; + +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 + 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..4b84ce158 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java @@ -0,0 +1,19 @@ +package org.c4marathon.assignment.domain.event.service; + +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); + } +} 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/global/configuration/WebConfiguration.java b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java index 36994cfff..5c64798de 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,6 +17,7 @@ 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) { @@ -30,5 +32,8 @@ public void addInterceptors(InterceptorRegistry registry) { registry .addInterceptor(deliveryCompanyInterceptor) .addPathPatterns("/orders/deliveries/**"); + registry + .addInterceptor(adminInterceptor) + .addPathPatterns("/event/**"); } } 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..096d504de 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,8 @@ 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가 이미 존재합니다."); 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"); + } +} From 94f75a149109d11529b331261cfccd134dfd2605 Mon Sep 17 00:00:00 2001 From: kariskan Date: Wed, 12 Jun 2024 18:54:32 +0900 Subject: [PATCH 03/33] =?UTF-8?q?fix:=20name=20unique=20constraint=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/discountpolicy/entity/DiscountPolicy.java | 4 ++++ .../org/c4marathon/assignment/domain/event/entity/Event.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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 index ed587b09c..051f0cd9b 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java @@ -31,6 +31,10 @@ public class DiscountPolicy extends BaseEntity { @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)") 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 index 40d66c3b4..3443ecdaa 100644 --- a/src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java +++ b/src/main/java/org/c4marathon/assignment/domain/event/entity/Event.java @@ -31,7 +31,7 @@ public class Event extends BaseEntity { private Long id; @NotNull - @Column(name = "name", columnDefinition = "VARCHAR(30)") + @Column(name = "name", columnDefinition = "VARCHAR(30)", unique = true) private String name; @NotNull From cbc09bb5ea81524ba519566bceb766ad854d45d6 Mon Sep 17 00:00:00 2001 From: kariskan Date: Wed, 12 Jun 2024 18:55:07 +0900 Subject: [PATCH 04/33] =?UTF-8?q?feat:=20DiscountPolicy=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DiscountPolicyController.java | 24 ++++++++++++ .../dto/request/DiscountPolicyRequest.java | 22 +++++++++++ .../entity/DiscountPolicyFactory.java | 15 ++++++++ .../repository/DiscountPolicyRepository.java | 9 +++++ .../service/DiscountPolicyReadService.java | 19 ++++++++++ .../service/DiscountPolicyService.java | 38 +++++++++++++++++++ .../configuration/WebConfiguration.java | 2 +- .../assignment/global/error/ErrorCode.java | 3 +- 8 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/dto/request/DiscountPolicyRequest.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicyFactory.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java 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..6a571b7ac --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java @@ -0,0 +1,24 @@ +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +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; + + @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..c146ea1b9 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/dto/request/DiscountPolicyRequest.java @@ -0,0 +1,22 @@ +package org.c4marathon.assignment.domain.discountpolicy.dto.request; + +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 +) { +} 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..350f03d55 --- /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..740de535a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java @@ -0,0 +1,19 @@ +package org.c4marathon.assignment.domain.discountpolicy.service; + +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); + } +} 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..224d00e0a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java @@ -0,0 +1,38 @@ +package org.c4marathon.assignment.domain.discountpolicy.service; + +import static org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicyFactory.*; +import static org.c4marathon.assignment.global.constant.DiscountType.*; +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) { + validateDiscountPolicy(request); + if (discountPolicyReadService.existsByName(request.name())) { + throw ALREADY_DISCOUNT_POLICY_EXISTS.baseException(); + } + discountPolicyRepository.save(buildDiscountPolicy(request)); + } + + private void validateDiscountPolicy(DiscountPolicyRequest request) { + if (request.discountType() == FIXED_DISCOUNT && request.discountAmount() == null) { + throw BIND_ERROR.baseException(); + } + if (request.discountType() == RATED_DISCOUNT && request.discountRate() == null) { + throw BIND_ERROR.baseException(); + } + } +} 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 5c64798de..2818fa338 100644 --- a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java +++ b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java @@ -34,6 +34,6 @@ public void addInterceptors(InterceptorRegistry registry) { .addPathPatterns("/orders/deliveries/**"); registry .addInterceptor(adminInterceptor) - .addPathPatterns("/event/**"); + .addPathPatterns("/event/**", "/discount-policy/**"); } } 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 096d504de..956005256 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -32,7 +32,8 @@ public enum ErrorCode { CONSUMER_NOT_FOUND_BY_ID(NOT_FOUND, "id에 해당하는 Consumer가 존재하지 않습니다."), REVIEW_ALREADY_EXISTS(CONFLICT, "해당 product에 대한 review가 이미 존재합니다."), NOT_POSSIBLE_CREATE_REVIEW(NOT_FOUND, "해당 product에 대한 구매 이력이 존재하지 않거나, 리뷰 작성 가능 기간이 지났습니다."), - ALREADY_EVENT_EXISTS(CONFLICT, "해당 name에 대한 event가 이미 존재합니다."); + ALREADY_EVENT_EXISTS(CONFLICT, "해당 name에 대한 event가 이미 존재합니다."), + ALREADY_DISCOUNT_POLICY_EXISTS(CONFLICT, "해당 name에 대한 discount_policy가 이미 존재합니다."); private final HttpStatus status; private final String message; From a208887ac371162a75bcd6067523661581fc0b6d Mon Sep 17 00:00:00 2001 From: kariskan Date: Thu, 13 Jun 2024 17:13:28 +0900 Subject: [PATCH 05/33] =?UTF-8?q?feat:=20Coupon=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/controller/CouponController.java | 24 +++++++++++ .../dto/request/CreateCouponRequest.java | 42 +++++++++++++++++++ .../domain/coupon/entity/Coupon.java | 6 --- .../domain/coupon/entity/CouponFactory.java | 23 ++++++++++ .../coupon/repository/CouponRepository.java | 9 ++++ .../coupon/service/CouponReadService.java | 17 ++++++++ .../domain/coupon/service/CouponService.java | 38 +++++++++++++++++ .../repository/DiscountPolicyRepository.java | 2 +- .../service/DiscountPolicyReadService.java | 5 +++ .../event/service/EventReadService.java | 5 +++ .../assignment/global/error/ErrorCode.java | 4 +- 11 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/dto/request/CreateCouponRequest.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/repository/CouponRepository.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java 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..c4d9b92df --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java @@ -0,0 +1,24 @@ +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +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; + + @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..b01fbe20a --- /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 validity, + 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 index fb3c3f91b..119f5db2e 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java @@ -49,10 +49,6 @@ public class Coupon extends BaseEntity { @Column(name = "discount_policy_id", columnDefinition = "BIGINT") private Long discountPolicyId; - @NotNull - @Column(name = "serial_number", columnDefinition = "VARCHAR(30)", updatable = false) - private String serialNumber; - @NotNull @Column(name = "event_id", columnDefinition = "BIGINT") private Long eventId; @@ -81,7 +77,6 @@ public Coupon( CouponType couponType, Boolean redundantUsable, Long discountPolicyId, - String serialNumber, Long eventId, LocalDateTime validity, Long maximumUsage, @@ -91,7 +86,6 @@ public Coupon( this.couponType = couponType; this.redundantUsable = redundantUsable; this.discountPolicyId = discountPolicyId; - this.serialNumber = serialNumber; this.eventId = eventId; this.validity = validity; this.maximumUsage = maximumUsage; 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..b6b0d0b8e --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java @@ -0,0 +1,23 @@ +package org.c4marathon.assignment.domain.coupon.entity; + +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()) + .validity(request.validity()) + .maximumUsage(request.maximumUsage()) + .maximumIssued(request.maximumIssued()) + .build(); + } +} 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/service/CouponReadService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java new file mode 100644 index 000000000..43d4f7fcb --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java @@ -0,0 +1,17 @@ +package org.c4marathon.assignment.domain.coupon.service; + +import org.c4marathon.assignment.domain.coupon.repository.CouponRepository; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CouponReadService { + + private final CouponRepository couponRepository; + + public boolean existsByName(String name) { + return couponRepository.existsByName(name); + } +} 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..dbbc34986 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -0,0 +1,38 @@ +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.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; + + @Transactional + public void createCoupon(CreateCouponRequest request) { + validateRequest(request); + couponRepository.save(CouponFactory.buildCoupon(request)); + } + + private void validateRequest(CreateCouponRequest request) { + request.validate(); + if (!discountPolicyReadService.existsById(request.discountPolicyId())) { + throw DISCOUNT_POLICY_NOT_FOUND.baseException(); + } + if (!eventReadService.existsById(request.eventId())) { + throw EVENT_NOT_FOUND.baseException(); + } + } +} 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 index 350f03d55..36e7d0fff 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/repository/DiscountPolicyRepository.java @@ -3,7 +3,7 @@ import org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicy; import org.springframework.data.jpa.repository.JpaRepository; -public interface DiscountPolicyRepository extends 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 index 740de535a..944155ad0 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java @@ -16,4 +16,9 @@ public class DiscountPolicyReadService { public boolean existsByName(String name) { return discountPolicyRepository.existsByName(name); } + + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return discountPolicyRepository.existsById(id); + } } 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 index 4b84ce158..f9a8c0b07 100644 --- a/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java @@ -16,4 +16,9 @@ public class EventReadService { public boolean existsByName(String name) { return eventRepository.existsByName(name); } + + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return eventRepository.existsById(id); + } } 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 956005256..e5547b0be 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -33,7 +33,9 @@ public enum ErrorCode { REVIEW_ALREADY_EXISTS(CONFLICT, "해당 product에 대한 review가 이미 존재합니다."), NOT_POSSIBLE_CREATE_REVIEW(NOT_FOUND, "해당 product에 대한 구매 이력이 존재하지 않거나, 리뷰 작성 가능 기간이 지났습니다."), ALREADY_EVENT_EXISTS(CONFLICT, "해당 name에 대한 event가 이미 존재합니다."), - ALREADY_DISCOUNT_POLICY_EXISTS(CONFLICT, "해당 name에 대한 discount_policy가 이미 존재합니다."); + ALREADY_DISCOUNT_POLICY_EXISTS(CONFLICT, "해당 name에 대한 discount_policy가 이미 존재합니다."), + DISCOUNT_POLICY_NOT_FOUND(NOT_FOUND, "요청에 해당하는 discount_policy가 존재하지 않습니다."), + EVENT_NOT_FOUND(NOT_FOUND, "요청에 해당하는 event가 존재하지 않습니다."); private final HttpStatus status; private final String message; From ecb4407d544d9139a1f69ff4b647ad23a9a40175 Mon Sep 17 00:00:00 2001 From: kariskan Date: Thu, 13 Jun 2024 17:18:55 +0900 Subject: [PATCH 06/33] =?UTF-8?q?fix:=20coupon=20=EC=83=9D=EC=84=B1=20vali?= =?UTF-8?q?dation=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/coupon/service/CouponService.java | 4 ++++ .../assignment/global/configuration/WebConfiguration.java | 2 +- .../org/c4marathon/assignment/global/error/ErrorCode.java | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) 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 index dbbc34986..1345a876d 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -19,6 +19,7 @@ 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) { @@ -34,5 +35,8 @@ private void validateRequest(CreateCouponRequest request) { if (!eventReadService.existsById(request.eventId())) { throw EVENT_NOT_FOUND.baseException(); } + if (couponReadService.existsByName(request.name())) { + throw ALREADY_COUPON_EXISTS.baseException(); + } } } 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 2818fa338..196068100 100644 --- a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java +++ b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java @@ -34,6 +34,6 @@ public void addInterceptors(InterceptorRegistry registry) { .addPathPatterns("/orders/deliveries/**"); registry .addInterceptor(adminInterceptor) - .addPathPatterns("/event/**", "/discount-policy/**"); + .addPathPatterns("/event/**", "/discount-policy/**", "/coupons/**"); } } 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 e5547b0be..acb77d688 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -35,7 +35,8 @@ public enum ErrorCode { 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가 존재하지 않습니다."); + EVENT_NOT_FOUND(NOT_FOUND, "요청에 해당하는 event가 존재하지 않습니다."), + ALREADY_COUPON_EXISTS(CONFLICT, "해당 name에 대한 coupon이 이미 존재합니다."); private final HttpStatus status; private final String message; From a5e0b27b06c2176b2e04231876d4920fddfcee1c Mon Sep 17 00:00:00 2001 From: kariskan Date: Mon, 17 Jun 2024 21:34:12 +0900 Subject: [PATCH 07/33] =?UTF-8?q?fix:=20column=20definition=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/issuedcoupon/entity/IssuedCoupon.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 7d71462b8..40dbaab29 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java @@ -31,8 +31,8 @@ public class IssuedCoupon extends BaseEntity { private Long couponId; @NotNull - @Column(name = "is_used", columnDefinition = "BIT default 0") - private Boolean isUsed; + @Column(name = "used_count", columnDefinition = "BIGINT") + private Long usedCount; @NotNull @Column(name = "consumer_id", columnDefinition = "BIGINT") From 27a4b9d8fa2dfe755f7c6a8c2434886c02d1b32c Mon Sep 17 00:00:00 2001 From: kariskan Date: Thu, 20 Jun 2024 21:32:59 +0900 Subject: [PATCH 08/33] feat: redisson configuration --- build.gradle | 1 + .../configuration/RedissonConfiguration.java | 26 +++++++++++++++++++ src/main/resources/application.yml | 5 ++++ 3 files changed, 32 insertions(+) create mode 100644 src/main/java/org/c4marathon/assignment/global/configuration/RedissonConfiguration.java 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/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/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 From 5c388a0fac4fc021c2adfcd95dae1c7dc67e880b Mon Sep 17 00:00:00 2001 From: kariskan Date: Thu, 20 Jun 2024 21:33:21 +0900 Subject: [PATCH 09/33] =?UTF-8?q?fix:=20requireNonNullElse=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/coupon/entity/CouponFactory.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index b6b0d0b8e..b600d63cf 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java @@ -1,5 +1,7 @@ package org.c4marathon.assignment.domain.coupon.entity; +import static java.util.Objects.*; + import org.c4marathon.assignment.domain.coupon.dto.request.CreateCouponRequest; import lombok.AccessLevel; @@ -16,8 +18,8 @@ public static Coupon buildCoupon(CreateCouponRequest request) { .discountPolicyId(request.discountPolicyId()) .eventId(request.eventId()) .validity(request.validity()) - .maximumUsage(request.maximumUsage()) - .maximumIssued(request.maximumIssued()) + .maximumUsage(requireNonNullElse(request.maximumUsage(), Long.MAX_VALUE)) + .maximumIssued(requireNonNullElse(request.maximumIssued(), Long.MAX_VALUE)) .build(); } } From 474946e3f3d2981c029fbb7dfeffe8b46de92696 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 00:50:20 +0900 Subject: [PATCH 10/33] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/IssuedCouponController.java | 25 ++++++++ .../dto/request/CouponIssueRequest.java | 6 ++ .../issuedcoupon/entity/IssuedCoupon.java | 24 +++++++- .../entity/IssuedCouponFactory.java | 18 ++++++ .../repository/IssuedCouponRepository.java | 19 ++++++ .../service/CouponRestrictionManager.java | 37 ++++++++++++ .../service/IssuedCouponReadService.java | 25 ++++++++ .../service/IssuedCouponService.java | 60 +++++++++++++++++++ .../service/LockedCouponService.java | 56 +++++++++++++++++ .../global/aop/CouponIssueLockAop.java | 56 +++++++++++++++++ .../aop/annotation/CouponIssueLock.java | 15 +++++ .../global/aop/annotation/TransactionAop.java | 15 +++++ .../global/common/CouponElParser.java | 21 +++++++ .../configuration/WebConfiguration.java | 2 +- 14 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/dto/request/CouponIssueRequest.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCouponFactory.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java create mode 100644 src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java create mode 100644 src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java create mode 100644 src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java create mode 100644 src/main/java/org/c4marathon/assignment/global/common/CouponElParser.java 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..7be11444c --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java @@ -0,0 +1,25 @@ +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +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; + + @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 index 40dbaab29..e838384b5 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java @@ -1,5 +1,7 @@ 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; @@ -10,6 +12,7 @@ import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -31,10 +34,27 @@ public class IssuedCoupon extends BaseEntity { private Long couponId; @NotNull - @Column(name = "used_count", columnDefinition = "BIGINT") - private Long usedCount; + @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 updateUsedCount() { + 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..c0921d6fe --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -0,0 +1,19 @@ +package org.c4marathon.assignment.domain.issuedcoupon.repository; + +import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface IssuedCouponRepository extends JpaRepository { + + @Query( + value = """ + select count(*) 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 + """, + nativeQuery = true + ) + Long countByConsumerIdAndEventId(@Param("consumerId") Long consumerId, @Param("eventId") Long eventId); +} 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..b723fa721 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java @@ -0,0 +1,37 @@ +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; + +@Component +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); + } +} 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..17e70ceb3 --- /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.countByConsumerIdAndEventId(consumerId, eventId) > 0; + } + + 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..c8e53d58c --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java @@ -0,0 +1,60 @@ +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 static org.c4marathon.assignment.global.error.ErrorCode.*; + +import java.time.LocalDateTime; + +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 IssuedCouponReadService issuedCouponReadService; + 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()); + validateExpiration(coupon.getExpiredTime()); + + if (coupon.getCouponType() == ISSUE_COUPON) { + couponRestrictionManager.validateCouponIssuable(coupon.getId()); + return lockedCouponService.increaseIssueCount(event.getId(), request, consumer); + } + return issuedCouponRepository.save(buildIssuedCoupon(request, consumer)).getId(); + } + + private void validateExpiration(LocalDateTime target) { + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(target)) { + throw RESOURCE_EXPIRED.baseException(); + } + } +} 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..850583233 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java @@ -0,0 +1,56 @@ +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; + + @CouponIssueLock(key = "#eventId") + public long increaseIssueCount(Long eventId, CouponIssueRequest request, Consumer consumer) { + validateRedundantIssue(eventId, consumer.getId(), request.couponId()); + Coupon coupon = couponReadService.findById(request.couponId()); + coupon.increaseIssuedCount(); + return issuedCouponRepository.save(buildIssuedCoupon(request, consumer)).getId(); + } + + @CouponIssueLock(key = "#couponId") + public void increaseUsedCount(Long couponId, Long issuedCouponId) { + Coupon coupon = couponReadService.findById(couponId); + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); + coupon.increaseUsedCount(); + issuedCoupon.updateUsedCount(); + } + + @CouponIssueLock(key = "#couponId") + public void decreaseUsedCount(Long couponId, Long issuedCouponId) { + Coupon coupon = couponReadService.findById(couponId); + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); + coupon.decreaseUsedCount(); + issuedCoupon.updateUsedCount(); + } + + private void validateRedundantIssue(Long eventId, Long consumerId, Long couponId) { + if (issuedCouponReadService.existsByConsumerIdAndEventId(consumerId, eventId)) { + couponRestrictionManager.addNotIssuableCoupon(couponId); + throw SINGLE_COUPON_AVAILABLE_PER_EVENT.baseException(); + } + } +} 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..9d0c886ed --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java @@ -0,0 +1,56 @@ +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; +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class CouponIssueLockAop { + + private static final String COUPON_ISSUE_LOCK_PREFIX = "EVENT_LOCK:"; + private final RedissonClient redissonClient; + private final TransactionAop transactionAop; + + @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..113842f1f --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java @@ -0,0 +1,15 @@ +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..9eea1d881 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java @@ -0,0 +1,15 @@ +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 { + + @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/WebConfiguration.java b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java index 196068100..084770465 100644 --- a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java +++ b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java @@ -23,7 +23,7 @@ public class WebConfiguration implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry .addInterceptor(consumerInterceptor) - .addPathPatterns("/consumers/**", "/reviews/**"); + .addPathPatterns("/consumers/**", "/reviews/**", "/issued-coupons/**"); registry .addInterceptor(sellerInterceptor) From 39a3917807ade3c92d771ccca99eaca10528fcf6 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 00:52:27 +0900 Subject: [PATCH 11/33] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/PurchaseProductRequest.java | 3 +- .../consumer/service/ConsumerService.java | 130 ++++++++++++++++-- .../domain/coupon/entity/Coupon.java | 51 +++++-- .../domain/coupon/entity/FailedCouponLog.java | 38 +++++ .../repository/FailedCouponLogRepository.java | 7 + .../coupon/service/CouponReadService.java | 11 ++ .../discountpolicy/entity/DiscountPolicy.java | 9 ++ .../service/DiscountPolicyReadService.java | 9 ++ .../event/service/EventReadService.java | 9 ++ .../assignment/domain/order/entity/Order.java | 7 + .../assignment/global/error/ErrorCode.java | 13 +- 11 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/entity/FailedCouponLog.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java 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..68650078c 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,20 @@ 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.entity.FailedCouponLog; +import org.c4marathon.assignment.domain.coupon.repository.FailedCouponLogRepository; +import org.c4marathon.assignment.domain.coupon.service.CouponReadService; 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 +62,54 @@ 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 FailedCouponLogRepository failedCouponLogRepository; /** * 상품 구매 * 최종 결제 금액 = 총 구입 금액 - 사용할 포인트 * 이후 구매 확정 단계에서 사용하기 위해 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) { + // 예외가 발생했을때 선착순 사용 쿠폰을 원상복구 해야함 + if (issuedCoupon != null && coupon != null && coupon.getCouponType() == USE_COUPON) { + int retryCount = 0; + while (true) { + try { + lockedCouponService.decreaseUsedCount(coupon.getId(), issuedCoupon.getId()); + couponRestrictionManager.addNotIssuableCoupon(coupon.getId()); + break; + } catch (Exception innerException) { + retryCount++; + if (retryCount >= 3) { + failedCouponLogRepository.save( + new FailedCouponLog(null, coupon.getId(), issuedCoupon.getId())); + throw innerException; + } + } + } + } + throw e; + } } /** @@ -85,6 +127,7 @@ public void refundOrder(Long orderId, Consumer consumer) { validateRefundRequest(consumer, order, delivery); updateStatusWhenRefund(order, delivery); + refundCoupon(order); savePointLog(consumer, order, false); } @@ -112,6 +155,71 @@ 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; + } + + /** + * 쿠폰 사용 + * 내가 발급받은 쿠폰이 아니면 exception + * 기간이 지난 쿠폰이면 exception + * 중복 불가능 쿠폰인데 이미 사용됐으면 exception + * 선착순 발급 쿠폰인 경우는 사용만 하면 되기 때문에 따로 락 메커니즘은 필요없음, 중복 사용도 가능함 + * 선착순 사용 쿠폰인 경우는 락 메커니즘이 필요함. + * 그래서 여기도 사용 횟수 다다르면 couponRestrictionManager에 캐싱해둠 + */ + 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.updateUsedCount(); + } + return coupon; + } + + private void validateRedundant(Coupon coupon, IssuedCoupon issuedCoupon) { + if (!coupon.getRedundantUsable() && issuedCoupon.getUsedCount() > 0) { + couponRestrictionManager.addNotUsableCoupon(coupon.getId()); + 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) { + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(order.getIssuedCouponId()); + Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId()); + if (coupon.getCouponType() != USE_COUPON) { + issuedCoupon.updateUsedCount(); + } + } + } + /** * 주문 시 product에 대한 구매 횟수를 증가함 */ @@ -120,11 +228,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 +338,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/entity/Coupon.java b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java index 119f5db2e..fcf68ed5b 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/Coupon.java @@ -1,5 +1,7 @@ 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; @@ -33,7 +35,7 @@ public class Coupon extends BaseEntity { private Long id; @NotNull - @Column(name = "name", columnDefinition = "VARCHAR(20)") + @Column(name = "name", columnDefinition = "VARCHAR(20)", unique = true) private String name; @NotNull @@ -54,8 +56,8 @@ public class Coupon extends BaseEntity { private Long eventId; @NotNull - @Column(name = "validity", columnDefinition = "DATETIME") - private LocalDateTime validity; + @Column(name = "expired_time", columnDefinition = "DATETIME") + private LocalDateTime expiredTime; @Column(name = "maximum_usage", columnDefinition = "BIGINT default " + Long.MAX_VALUE) private Long maximumUsage; @@ -64,8 +66,8 @@ public class Coupon extends BaseEntity { private Long maximumIssued; @NotNull - @Column(name = "use_count", columnDefinition = "BIGINT default 0") - private Long useCount; + @Column(name = "used_count", columnDefinition = "BIGINT default 0") + private Long usedCount; @NotNull @Column(name = "issued_count", columnDefinition = "BIGINT default 0") @@ -78,7 +80,7 @@ public Coupon( Boolean redundantUsable, Long discountPolicyId, Long eventId, - LocalDateTime validity, + LocalDateTime expiredTime, Long maximumUsage, Long maximumIssued ) { @@ -87,10 +89,43 @@ public Coupon( this.redundantUsable = redundantUsable; this.discountPolicyId = discountPolicyId; this.eventId = eventId; - this.validity = validity; + this.expiredTime = expiredTime; this.maximumUsage = maximumUsage; this.maximumIssued = maximumIssued; - this.useCount = 0L; + 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/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/FailedCouponLogRepository.java b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java new file mode 100644 index 000000000..6da3d6521 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -0,0 +1,7 @@ +package org.c4marathon.assignment.domain.coupon.repository; + +import org.c4marathon.assignment.domain.coupon.entity.FailedCouponLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FailedCouponLogRepository extends JpaRepository { +} 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 index 43d4f7fcb..f93f9f250 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponReadService.java @@ -1,7 +1,11 @@ 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; @@ -11,7 +15,14 @@ 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/discountpolicy/entity/DiscountPolicy.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java index 051f0cd9b..28ffce624 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/entity/DiscountPolicy.java @@ -1,5 +1,7 @@ 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; @@ -45,4 +47,11 @@ public class DiscountPolicy extends BaseEntity { @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/service/DiscountPolicyReadService.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java index 944155ad0..c25260625 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyReadService.java @@ -1,5 +1,8 @@ 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; @@ -21,4 +24,10 @@ public boolean existsByName(String name) { 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/event/service/EventReadService.java b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java index f9a8c0b07..7f10ce4d8 100644 --- a/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/event/service/EventReadService.java @@ -1,5 +1,8 @@ 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; @@ -21,4 +24,10 @@ public boolean existsByName(String name) { 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/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/error/ErrorCode.java b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java index acb77d688..51605a87f 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -36,7 +36,18 @@ public enum ErrorCode { 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이 이미 존재합니다."); + 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; From 77d63428fde2d0d74db985cb36b58994454aeaeb Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 00:53:04 +0900 Subject: [PATCH 12/33] =?UTF-8?q?fix:=20@Future=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/dto/request/PublishEventRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 802173777..a259d5626 100644 --- 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 @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -11,6 +12,7 @@ public record PublishEventRequest( @Size(max = 30) String name, @NotNull + @Future LocalDateTime endDate ) { } From c8b1f6988e797360f28f70eca94ffe8a334536f8 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 00:53:28 +0900 Subject: [PATCH 13/33] =?UTF-8?q?refactor:=20coupon=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EB=B3=80=EC=88=98=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/coupon/dto/request/CreateCouponRequest.java | 2 +- .../assignment/domain/coupon/entity/CouponFactory.java | 2 +- .../assignment/domain/coupon/service/CouponService.java | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) 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 index b01fbe20a..b2621d9a5 100644 --- 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 @@ -22,7 +22,7 @@ public record CreateCouponRequest( long eventId, @NotNull @Future - LocalDateTime validity, + LocalDateTime expiredTime, Long maximumUsage, Long maximumIssued ) { 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 index b600d63cf..f69b3326a 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/entity/CouponFactory.java @@ -17,7 +17,7 @@ public static Coupon buildCoupon(CreateCouponRequest request) { .redundantUsable(request.redundantUsable()) .discountPolicyId(request.discountPolicyId()) .eventId(request.eventId()) - .validity(request.validity()) + .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/service/CouponService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java index 1345a876d..e0650c8fe 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -6,6 +6,7 @@ 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; @@ -28,12 +29,13 @@ public void createCoupon(CreateCouponRequest request) { } private void validateRequest(CreateCouponRequest request) { + Event event = eventReadService.findById(request.eventId()); request.validate(); if (!discountPolicyReadService.existsById(request.discountPolicyId())) { throw DISCOUNT_POLICY_NOT_FOUND.baseException(); } - if (!eventReadService.existsById(request.eventId())) { - throw EVENT_NOT_FOUND.baseException(); + if (event.getEndDate().isBefore(request.expiredTime())) { + throw INVALID_COUPON_EXPIRED_TIME.baseException(); } if (couponReadService.existsByName(request.name())) { throw ALREADY_COUPON_EXISTS.baseException(); From 05715fb20640b4eb16c17913455af3a4aea5318e Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 14:39:21 +0900 Subject: [PATCH 14/33] =?UTF-8?q?fix:=20exists=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/IssuedCouponRepository.java | 15 +++++++-------- .../service/IssuedCouponReadService.java | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) 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 index c0921d6fe..ce5264f69 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -3,17 +3,16 @@ import org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCoupon; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface IssuedCouponRepository extends JpaRepository { - @Query( - value = """ - select count(*) 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 - """, + @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 countByConsumerIdAndEventId(@Param("consumerId") Long consumerId, @Param("eventId") Long eventId); + Long existsByConsumerIdCouponId_EventId(Long consumerId, Long eventId); } 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 index 17e70ceb3..1eb4dcdbe 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponReadService.java @@ -15,7 +15,7 @@ public class IssuedCouponReadService { private final IssuedCouponRepository issuedCouponRepository; public boolean existsByConsumerIdAndEventId(Long consumerId, Long eventId) { - return issuedCouponRepository.countByConsumerIdAndEventId(consumerId, eventId) > 0; + return issuedCouponRepository.existsByConsumerIdCouponId_EventId(consumerId, eventId) != null; } public IssuedCoupon findById(Long id) { From dfd111058af667aaf609b9cad393bf3287adfaa8 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 15:05:58 +0900 Subject: [PATCH 15/33] =?UTF-8?q?fix:=20decreaseUsedCount()=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/consumer/service/ConsumerService.java | 4 ++-- .../assignment/domain/issuedcoupon/entity/IssuedCoupon.java | 6 +++++- .../domain/issuedcoupon/service/LockedCouponService.java | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) 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 68650078c..49b6a25ef 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 @@ -184,7 +184,7 @@ private Coupon useCoupon(IssuedCoupon issuedCoupon) { couponRestrictionManager.validateCouponUsable(coupon.getId()); lockedCouponService.increaseUsedCount(coupon.getId(), issuedCoupon.getId()); } else { - issuedCoupon.updateUsedCount(); + issuedCoupon.increaseUsedCount(); } return coupon; } @@ -215,7 +215,7 @@ private void refundCoupon(Order order) { IssuedCoupon issuedCoupon = issuedCouponReadService.findById(order.getIssuedCouponId()); Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId()); if (coupon.getCouponType() != USE_COUPON) { - issuedCoupon.updateUsedCount(); + issuedCoupon.decreaseUsedCount(); } } } 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 index e838384b5..cf8600253 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/entity/IssuedCoupon.java @@ -54,7 +54,11 @@ public void validatePermission(Long targetId) { } } - public void updateUsedCount() { + public void increaseUsedCount() { usedCount++; } + + public void decreaseUsedCount() { + usedCount--; + } } 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 index 850583233..2d7e0a446 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java @@ -36,7 +36,7 @@ public void increaseUsedCount(Long couponId, Long issuedCouponId) { Coupon coupon = couponReadService.findById(couponId); IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); coupon.increaseUsedCount(); - issuedCoupon.updateUsedCount(); + issuedCoupon.increaseUsedCount(); } @CouponIssueLock(key = "#couponId") @@ -44,7 +44,7 @@ public void decreaseUsedCount(Long couponId, Long issuedCouponId) { Coupon coupon = couponReadService.findById(couponId); IssuedCoupon issuedCoupon = issuedCouponReadService.findById(issuedCouponId); coupon.decreaseUsedCount(); - issuedCoupon.updateUsedCount(); + issuedCoupon.decreaseUsedCount(); } private void validateRedundantIssue(Long eventId, Long consumerId, Long couponId) { From b52dcc8faa2a0205ed9c87f64df46b395317e1af Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 15:06:14 +0900 Subject: [PATCH 16/33] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/coupon/service/CouponService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index e0650c8fe..5532b95d0 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -29,16 +29,16 @@ public void createCoupon(CreateCouponRequest request) { } private void validateRequest(CreateCouponRequest request) { - Event event = eventReadService.findById(request.eventId()); 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(); } - if (couponReadService.existsByName(request.name())) { - throw ALREADY_COUPON_EXISTS.baseException(); - } } } From 8b9f91bdea03facf6ddab84d94ef1c04c83faf91 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 15:06:35 +0900 Subject: [PATCH 17/33] =?UTF-8?q?refactor:=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/DiscountPolicyRequest.java | 12 ++++++++++++ .../service/DiscountPolicyService.java | 12 +----------- .../issuedcoupon/service/IssuedCouponService.java | 9 +-------- 3 files changed, 14 insertions(+), 19 deletions(-) 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 index c146ea1b9..eca83421d 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -19,4 +22,13 @@ public record DiscountPolicyRequest( @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/service/DiscountPolicyService.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java index 224d00e0a..2946d9bfb 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/service/DiscountPolicyService.java @@ -1,7 +1,6 @@ package org.c4marathon.assignment.domain.discountpolicy.service; import static org.c4marathon.assignment.domain.discountpolicy.entity.DiscountPolicyFactory.*; -import static org.c4marathon.assignment.global.constant.DiscountType.*; import static org.c4marathon.assignment.global.error.ErrorCode.*; import org.c4marathon.assignment.domain.discountpolicy.dto.request.DiscountPolicyRequest; @@ -20,19 +19,10 @@ public class DiscountPolicyService { @Transactional public void createDiscountPolicy(DiscountPolicyRequest request) { - validateDiscountPolicy(request); + request.validate(); if (discountPolicyReadService.existsByName(request.name())) { throw ALREADY_DISCOUNT_POLICY_EXISTS.baseException(); } discountPolicyRepository.save(buildDiscountPolicy(request)); } - - private void validateDiscountPolicy(DiscountPolicyRequest request) { - if (request.discountType() == FIXED_DISCOUNT && request.discountAmount() == null) { - throw BIND_ERROR.baseException(); - } - if (request.discountType() == RATED_DISCOUNT && request.discountRate() == null) { - throw BIND_ERROR.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 index c8e53d58c..05d10ad45 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java @@ -42,7 +42,7 @@ public class IssuedCouponService { public long issueCoupon(CouponIssueRequest request, Consumer consumer) { Coupon coupon = couponReadService.findById(request.couponId()); Event event = eventReadService.findById(coupon.getEventId()); - validateExpiration(coupon.getExpiredTime()); + coupon.validateTime(); if (coupon.getCouponType() == ISSUE_COUPON) { couponRestrictionManager.validateCouponIssuable(coupon.getId()); @@ -50,11 +50,4 @@ public long issueCoupon(CouponIssueRequest request, Consumer consumer) { } return issuedCouponRepository.save(buildIssuedCoupon(request, consumer)).getId(); } - - private void validateExpiration(LocalDateTime target) { - LocalDateTime now = LocalDateTime.now(); - if (now.isAfter(target)) { - throw RESOURCE_EXPIRED.baseException(); - } - } } From 1ac9755b6f57e23529ab79e6a1fb474a602990a0 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 15:06:43 +0900 Subject: [PATCH 18/33] style: code reformat --- .../repository/IssuedCouponRepository.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index ce5264f69..ffbc4585e 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -7,10 +7,12 @@ 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 + 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 ) From ea4e47834b5596eee6e0dbaaac94ea3462ebd008 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 16:15:07 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat:=20cache=20eviction=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/issuedcoupon/service/CouponRestrictionManager.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index b723fa721..9a9c8df17 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/CouponRestrictionManager.java @@ -34,4 +34,8 @@ public void validateCouponUsable(long couponId) { public void addNotUsableCoupon(long couponId) { notUsableCoupons.add(couponId); } + + public void removeNotUsableCoupon(long couponId) { + notUsableCoupons.remove(couponId); + } } From 2fc463232f3c913a8e5bd2ae128e0953c9704789 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 16:15:34 +0900 Subject: [PATCH 20/33] =?UTF-8?q?feat:=20decrease=20use=20count=20?= =?UTF-8?q?=EB=B0=8F=20scheduler=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/service/ConsumerService.java | 24 ++-------- .../repository/FailedCouponLogRepository.java | 8 ++++ .../coupon/service/CouponRetryService.java | 45 +++++++++++++++++++ .../service/FailedCouponLogService.java | 26 +++++++++++ .../scheduler/FailedCouponScheduler.java | 42 +++++++++++++++++ 5 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java create mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java create mode 100644 src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java 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 49b6a25ef..6d4e0fe71 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 @@ -15,9 +15,8 @@ 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.entity.FailedCouponLog; -import org.c4marathon.assignment.domain.coupon.repository.FailedCouponLogRepository; 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; @@ -67,7 +66,7 @@ public class ConsumerService { private final LockedCouponService lockedCouponService; private final DiscountPolicyReadService discountPolicyReadService; private final CouponRestrictionManager couponRestrictionManager; - private final FailedCouponLogRepository failedCouponLogRepository; + private final CouponRetryService couponRetryService; /** * 상품 구매 @@ -89,25 +88,10 @@ public void purchaseProduct(PurchaseProductRequest request, Consumer consumer) { long totalAmount = calculateTotalAmount(orderProducts, coupon); decreaseBalance(consumer, totalAmount - request.point()); saveOrderInfo(request, consumer, order, orderProducts, totalAmount, issuedCoupon); + throw new RuntimeException(); } catch (Exception e) { // 예외가 발생했을때 선착순 사용 쿠폰을 원상복구 해야함 - if (issuedCoupon != null && coupon != null && coupon.getCouponType() == USE_COUPON) { - int retryCount = 0; - while (true) { - try { - lockedCouponService.decreaseUsedCount(coupon.getId(), issuedCoupon.getId()); - couponRestrictionManager.addNotIssuableCoupon(coupon.getId()); - break; - } catch (Exception innerException) { - retryCount++; - if (retryCount >= 3) { - failedCouponLogRepository.save( - new FailedCouponLog(null, coupon.getId(), issuedCoupon.getId())); - throw innerException; - } - } - } - } + couponRetryService.decreaseUsedCount(issuedCoupon, coupon); throw e; } } 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 index 6da3d6521..fd1ef27d7 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -1,7 +1,15 @@ 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 order by fcl.failed_coupon_log_id limit 1 + """, nativeQuery = true) + Optional findFirst(); } 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..99753a7e1 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java @@ -0,0 +1,45 @@ +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.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 { + + private final LockedCouponService lockedCouponService; + private final CouponRestrictionManager couponRestrictionManager; + private final FailedCouponLogService failedCouponLogService; + + @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) { + retryCount++; + if (retryCount >= 3) { + failedCouponLogService.saveFailedCouponLog(issuedCoupon, coupon); + throw innerException; + } + } + } + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java new file mode 100644 index 000000000..4533d3659 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java @@ -0,0 +1,26 @@ +package org.c4marathon.assignment.domain.coupon.service; + +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.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FailedCouponLogService { + + private final FailedCouponLogRepository failedCouponLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveFailedCouponLog(IssuedCoupon issuedCoupon, Coupon coupon) { + log.info("save failed coupon log"); + failedCouponLogRepository.save(new FailedCouponLog(null, coupon.getId(), issuedCoupon.getId())); + } +} 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..47b664c97 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java @@ -0,0 +1,42 @@ +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; + + @Scheduled(fixedDelay = 5000) + @Transactional + public void processFailedCouponLog() { + 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); + } + } + } +} From f73e636e7edd9e66341ec6713de4b17f489364ea Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 16:17:29 +0900 Subject: [PATCH 21/33] =?UTF-8?q?fix:=20order=20by=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/coupon/repository/FailedCouponLogRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fd1ef27d7..24be44aad 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -9,7 +9,7 @@ public interface FailedCouponLogRepository extends JpaRepository { @Query(value = """ - select * from failed_coupon_log_tbl fcl order by fcl.failed_coupon_log_id limit 1 + select * from failed_coupon_log_tbl fcl limit 1 """, nativeQuery = true) Optional findFirst(); } From aa9e2ec1f31bc2f182709f6099045e77cd1bda26 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 16:28:18 +0900 Subject: [PATCH 22/33] =?UTF-8?q?feat:=20RuntimeException=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/consumer/service/ConsumerService.java | 1 - 1 file changed, 1 deletion(-) 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 6d4e0fe71..0ae542a51 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 @@ -88,7 +88,6 @@ public void purchaseProduct(PurchaseProductRequest request, Consumer consumer) { long totalAmount = calculateTotalAmount(orderProducts, coupon); decreaseBalance(consumer, totalAmount - request.point()); saveOrderInfo(request, consumer, order, orderProducts, totalAmount, issuedCoupon); - throw new RuntimeException(); } catch (Exception e) { // 예외가 발생했을때 선착순 사용 쿠폰을 원상복구 해야함 couponRetryService.decreaseUsedCount(issuedCoupon, coupon); From 3fed571cb31ac85088e9206aef52b26d5386cac4 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:17:03 +0900 Subject: [PATCH 23/33] =?UTF-8?q?feat:=20=EC=84=A0=EC=B0=A9=EC=88=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=BF=A0=ED=8F=B0=20=ED=9A=8C=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FailedCouponLogRepository.java | 2 + .../repository/IssuedCouponRepository.java | 9 +++++ .../service/CouponRestrictionManager.java | 3 ++ .../scheduler/CouponWithdrawScheduler.java | 40 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java 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 index 24be44aad..e510a4faf 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -12,4 +12,6 @@ public interface FailedCouponLogRepository extends JpaRepository findFirst(); + + boolean existsByCouponId(Long couponId); } 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 index ffbc4585e..d5002ecb7 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -2,6 +2,7 @@ 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; public interface IssuedCouponRepository extends JpaRepository { @@ -17,4 +18,12 @@ public interface IssuedCouponRepository extends JpaRepository Date: Sat, 22 Jun 2024 20:25:56 +0900 Subject: [PATCH 24/33] refactor: reverse if-else --- .../domain/consumer/service/ConsumerService.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 0ae542a51..723ed384c 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 @@ -194,12 +194,13 @@ private long calculateTotalAmount(List orderProducts, Coupon coupo * 환불은 선착순 발급 쿠폰만 가능하기 때문에 동시성 제어를 딱히 할 필요가 없음 */ private void refundCoupon(Order order) { - if (order.getIssuedCouponId() != null) { - IssuedCoupon issuedCoupon = issuedCouponReadService.findById(order.getIssuedCouponId()); - Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId()); - if (coupon.getCouponType() != USE_COUPON) { - issuedCoupon.decreaseUsedCount(); - } + if (order.getIssuedCouponId() == null) { + return; + } + IssuedCoupon issuedCoupon = issuedCouponReadService.findById(order.getIssuedCouponId()); + Coupon coupon = couponReadService.findById(issuedCoupon.getCouponId()); + if (coupon.getCouponType() != USE_COUPON) { + issuedCoupon.decreaseUsedCount(); } } From e5d6c43c557685b98de4edbfc3ff13cbdc99160b Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:37:51 +0900 Subject: [PATCH 25/33] =?UTF-8?q?fix:=20http=20status=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/coupon/controller/CouponController.java | 3 +++ .../discountpolicy/controller/DiscountPolicyController.java | 3 +++ .../domain/issuedcoupon/controller/IssuedCouponController.java | 3 +++ 3 files changed, 9 insertions(+) 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 index c4d9b92df..395dc1381 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/controller/CouponController.java @@ -2,9 +2,11 @@ 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; @@ -17,6 +19,7 @@ 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/discountpolicy/controller/DiscountPolicyController.java b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java index 6a571b7ac..745bc376d 100644 --- a/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java +++ b/src/main/java/org/c4marathon/assignment/domain/discountpolicy/controller/DiscountPolicyController.java @@ -2,9 +2,11 @@ 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; @@ -17,6 +19,7 @@ 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/issuedcoupon/controller/IssuedCouponController.java b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java index 7be11444c..7fde20a1d 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/controller/IssuedCouponController.java @@ -3,9 +3,11 @@ 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; @@ -18,6 +20,7 @@ public class IssuedCouponController { private final IssuedCouponService issuedCouponService; + @ResponseStatus(HttpStatus.CREATED) @PostMapping public long issueCoupon(@Valid @RequestBody CouponIssueRequest request) { return issuedCouponService.issueCoupon(request, ConsumerThreadLocal.get()); From 0f1c3b5e21e54e2856468eb890f3215addedc71a Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:38:04 +0900 Subject: [PATCH 26/33] =?UTF-8?q?fix:=20fixedRate=20->=20fixedDelay?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/global/scheduler/CouponWithdrawScheduler.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java index 83f74e0f8..7503bef0f 100644 --- a/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java @@ -24,12 +24,13 @@ public class CouponWithdrawScheduler { // failed_coupon_log에 coupon_id가 없고, used_map에 있는 것 중에서, 쿠폰 유효 기간이 지난 것을 삭제해야함 // failed_coupon_log에 채워지기 전에 이 스케줄러가 돌아가지고 existsByCouponId가 false를 리턴하면 어떡하지..? - @Scheduled(fixedRate = 60 * 1_000) + @Scheduled(fixedDelay = 60 * 1_000) @Transactional public void processCouponWithdraw() { for (Long usedCouponId : couponRestrictionManager.getNotUsableCoupons()) { Coupon coupon = couponReadService.findById(usedCouponId); - if (coupon.getExpiredTime().isBefore(LocalDateTime.now())) { + LocalDateTime now = LocalDateTime.now(); + if (coupon.getExpiredTime().isBefore(now)) { if (!failedCouponLogRepository.existsByCouponId(usedCouponId)) { issuedCouponRepository.deleteByCouponId(usedCouponId); couponRestrictionManager.removeNotUsableCoupon(usedCouponId); From f9d9fe53b3fbc9bd368132b107223e1b85aaa6cf Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:38:17 +0900 Subject: [PATCH 27/33] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../c4marathon/assignment/global/aop/CouponIssueLockAop.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java index 9d0c886ed..2f74b9e53 100644 --- a/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java +++ b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java @@ -16,12 +16,10 @@ import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; @Aspect @Component @RequiredArgsConstructor -@Slf4j public class CouponIssueLockAop { private static final String COUPON_ISSUE_LOCK_PREFIX = "EVENT_LOCK:"; From b4c9fa79c398f990cb9142446d47136f75793657 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:38:30 +0900 Subject: [PATCH 28/33] =?UTF-8?q?refactor:=20magic=20number=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/domain/coupon/service/CouponRetryService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 99753a7e1..6d235d34d 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java @@ -18,6 +18,7 @@ @RequiredArgsConstructor public class CouponRetryService { + public static final int MAXIMUM_RETRY_COUNT = 3; private final LockedCouponService lockedCouponService; private final CouponRestrictionManager couponRestrictionManager; private final FailedCouponLogService failedCouponLogService; @@ -34,8 +35,9 @@ public void decreaseUsedCount(IssuedCoupon issuedCoupon, Coupon coupon) { couponRestrictionManager.removeNotUsableCoupon(coupon.getId()); break; } catch (Exception innerException) { + log.info("failed decrease coupon use count. retry count: {}", retryCount); retryCount++; - if (retryCount >= 3) { + if (retryCount >= MAXIMUM_RETRY_COUNT) { failedCouponLogService.saveFailedCouponLog(issuedCoupon, coupon); throw innerException; } From ef83c70c83fb7510b20c368ceeb1d9916f1d7731 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 20:39:02 +0900 Subject: [PATCH 29/33] style: code reformat --- .../domain/coupon/repository/FailedCouponLogRepository.java | 4 +++- .../domain/issuedcoupon/service/IssuedCouponService.java | 3 --- .../assignment/global/aop/annotation/CouponIssueLock.java | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) 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 index e510a4faf..5dc92dcf1 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -9,7 +9,9 @@ public interface FailedCouponLogRepository extends JpaRepository { @Query(value = """ - select * from failed_coupon_log_tbl fcl limit 1 + select * + from failed_coupon_log_tbl fcl + limit 1 """, nativeQuery = true) Optional findFirst(); 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 index 05d10ad45..dc0b43341 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java @@ -2,9 +2,6 @@ import static org.c4marathon.assignment.domain.issuedcoupon.entity.IssuedCouponFactory.*; import static org.c4marathon.assignment.global.constant.CouponType.*; -import static org.c4marathon.assignment.global.error.ErrorCode.*; - -import java.time.LocalDateTime; import org.c4marathon.assignment.domain.consumer.entity.Consumer; import org.c4marathon.assignment.domain.coupon.entity.Coupon; 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 index 113842f1f..740f16111 100644 --- a/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/CouponIssueLock.java @@ -10,6 +10,8 @@ public @interface CouponIssueLock { String key() default ""; + long waitTime() default 5; + long leaseTime() default 1; } From 87f39ea5ea46d3fe9202b4aafe3f95ef45c66a04 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 21:41:08 +0900 Subject: [PATCH 30/33] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/service/ConsumerService.java | 16 ++++++----- .../repository/FailedCouponLogRepository.java | 2 -- .../coupon/service/CouponRetryService.java | 11 ++++++-- .../domain/coupon/service/CouponService.java | 6 +++++ .../service/FailedCouponLogService.java | 26 ------------------ .../repository/IssuedCouponRepository.java | 12 +++++++++ .../service/IssuedCouponService.java | 3 +-- .../service/LockedCouponService.java | 21 ++++++++++++++- .../global/aop/CouponIssueLockAop.java | 4 +++ .../global/aop/annotation/TransactionAop.java | 5 ++++ .../scheduler/CouponWithdrawScheduler.java | 27 +++++++------------ .../scheduler/FailedCouponScheduler.java | 6 ++++- 12 files changed, 82 insertions(+), 57 deletions(-) delete mode 100644 src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java 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 723ed384c..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 @@ -70,7 +70,7 @@ public class ConsumerService { /** * 상품 구매 - * 최종 결제 금액 = 총 구입 금액 - 사용할 포인트 + * 최종 결제 금액 = (총 구입 금액 - 할인된 금액) - 사용할 포인트 * 이후 구매 확정 단계에서 사용하기 위해 Order Entity에 포인트 관련 필드를 추가 * 선착순 사용 쿠폰일 경우에, 주문을 하고 환불을 해도 쿠폰은 환불되지 않음. * 포인트 같은 경우에는 모든 할인(총 주문 금액 - 쿠폰 할인 - 포인트 사용 금액)이 적용된 최종 결제 금액에 5퍼센트가 적용됨. @@ -149,12 +149,14 @@ private IssuedCoupon getIssuedCoupon(Long issuedCouponId, Consumer consumer) { /** * 쿠폰 사용 - * 내가 발급받은 쿠폰이 아니면 exception - * 기간이 지난 쿠폰이면 exception - * 중복 불가능 쿠폰인데 이미 사용됐으면 exception * 선착순 발급 쿠폰인 경우는 사용만 하면 되기 때문에 따로 락 메커니즘은 필요없음, 중복 사용도 가능함 * 선착순 사용 쿠폰인 경우는 락 메커니즘이 필요함. - * 그래서 여기도 사용 횟수 다다르면 couponRestrictionManager에 캐싱해둠 + * 사용 횟수 다다르면 couponRestrictionManager에 캐싱해둠 + * 그래서 분산락 얻기 전에 캐싱된 데이터 보고 레디스 접근을 최소화함. + * @throws org.c4marathon.assignment.global.error.BaseException + * 내가 발급받은 쿠폰이 아닌 경우 + * 기간이 지난 쿠폰인 경우 + * 중복 불가능 쿠폰인데 이미 사용된 경우 */ private Coupon useCoupon(IssuedCoupon issuedCoupon) { if (issuedCoupon == null) { @@ -172,9 +174,11 @@ private Coupon useCoupon(IssuedCoupon issuedCoupon) { return coupon; } + /** + * 쿠폰이 중복 사용 불가능하고 이미 사용된 경우, 예외를 반환함 + */ private void validateRedundant(Coupon coupon, IssuedCoupon issuedCoupon) { if (!coupon.getRedundantUsable() && issuedCoupon.getUsedCount() > 0) { - couponRestrictionManager.addNotUsableCoupon(coupon.getId()); throw ALREADY_USED_COUPON.baseException(); } } 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 index 5dc92dcf1..85df7e55d 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/repository/FailedCouponLogRepository.java @@ -14,6 +14,4 @@ public interface FailedCouponLogRepository extends JpaRepository findFirst(); - - boolean existsByCouponId(Long couponId); } 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 index 6d235d34d..ce890bd12 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponRetryService.java @@ -3,6 +3,8 @@ 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; @@ -21,8 +23,13 @@ public class CouponRetryService { public static final int MAXIMUM_RETRY_COUNT = 3; private final LockedCouponService lockedCouponService; private final CouponRestrictionManager couponRestrictionManager; - private final FailedCouponLogService failedCouponLogService; + 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) { @@ -38,7 +45,7 @@ public void decreaseUsedCount(IssuedCoupon issuedCoupon, Coupon coupon) { log.info("failed decrease coupon use count. retry count: {}", retryCount); retryCount++; if (retryCount >= MAXIMUM_RETRY_COUNT) { - failedCouponLogService.saveFailedCouponLog(issuedCoupon, coupon); + 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 index 5532b95d0..bf282620a 100644 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/coupon/service/CouponService.java @@ -28,6 +28,12 @@ public void createCoupon(CreateCouponRequest 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())) { diff --git a/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java b/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java deleted file mode 100644 index 4533d3659..000000000 --- a/src/main/java/org/c4marathon/assignment/domain/coupon/service/FailedCouponLogService.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.c4marathon.assignment.domain.coupon.service; - -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.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class FailedCouponLogService { - - private final FailedCouponLogRepository failedCouponLogRepository; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void saveFailedCouponLog(IssuedCoupon issuedCoupon, Coupon coupon) { - log.info("save failed coupon log"); - failedCouponLogRepository.save(new FailedCouponLog(null, coupon.getId(), issuedCoupon.getId())); - } -} 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 index d5002ecb7..345dd012b 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -1,9 +1,13 @@ 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 { @@ -26,4 +30,12 @@ public interface IssuedCouponRepository extends JpaRepository findExpiredCouponId(@Param("now") LocalDateTime now); } 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 index dc0b43341..7831e868c 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/IssuedCouponService.java @@ -20,7 +20,6 @@ public class IssuedCouponService { private final IssuedCouponRepository issuedCouponRepository; - private final IssuedCouponReadService issuedCouponReadService; private final CouponReadService couponReadService; private final EventReadService eventReadService; private final LockedCouponService lockedCouponService; @@ -31,7 +30,7 @@ public class IssuedCouponService { * 하나의 이벤트에 여러개의 쿠폰이 생성될 수 있는데, 소비자는 하나의 이벤트에 하나의 쿠폰만 발급받을 수 있음 * 선착순 발급 쿠폰일 때, 캐싱된 "이미 발급된 쿠폰 수"가 최대 발급 가능 수 이상이면(높을리는 없겠지만) 예외 터트림 * 이걸로 일단 레디스 접근을 최소화하고.. - * 그게 아니라면 락 얻고 확인한 다음에 가능하면 쿠폰 발급함. + * 그게 아니라면 락 잡고 확인한 다음에 가능하면 쿠폰 발급함. * 선착순 발급 쿠폰은 이벤트 하나 당 한 개의 쿠폰만 발급받을 수 있음 * 선착순 사용 쿠폰은 이벤트 하나 당 여러개의 쿠폰을 발급받을 수 있음 */ 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 index 2d7e0a446..6b82676e1 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java @@ -23,22 +23,42 @@ public class LockedCouponService { 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(), request.couponId()); 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); @@ -49,7 +69,6 @@ public void decreaseUsedCount(Long couponId, Long issuedCouponId) { private void validateRedundantIssue(Long eventId, Long consumerId, Long couponId) { if (issuedCouponReadService.existsByConsumerIdAndEventId(consumerId, eventId)) { - couponRestrictionManager.addNotIssuableCoupon(couponId); throw SINGLE_COUPON_AVAILABLE_PER_EVENT.baseException(); } } diff --git a/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java index 2f74b9e53..e5df60969 100644 --- a/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java +++ b/src/main/java/org/c4marathon/assignment/global/aop/CouponIssueLockAop.java @@ -26,6 +26,10 @@ public class CouponIssueLockAop { 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(); 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 index 9eea1d881..472f35a4f 100644 --- a/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java +++ b/src/main/java/org/c4marathon/assignment/global/aop/annotation/TransactionAop.java @@ -8,6 +8,11 @@ @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/scheduler/CouponWithdrawScheduler.java b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java index 7503bef0f..ac474402d 100644 --- a/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/CouponWithdrawScheduler.java @@ -1,10 +1,8 @@ package org.c4marathon.assignment.global.scheduler; import java.time.LocalDateTime; +import java.util.List; -import org.c4marathon.assignment.domain.coupon.entity.Coupon; -import org.c4marathon.assignment.domain.coupon.repository.FailedCouponLogRepository; -import org.c4marathon.assignment.domain.coupon.service.CouponReadService; import org.c4marathon.assignment.domain.issuedcoupon.repository.IssuedCouponRepository; import org.c4marathon.assignment.domain.issuedcoupon.service.CouponRestrictionManager; import org.springframework.scheduling.annotation.Scheduled; @@ -18,24 +16,19 @@ public class CouponWithdrawScheduler { private final CouponRestrictionManager couponRestrictionManager; - private final FailedCouponLogRepository failedCouponLogRepository; private final IssuedCouponRepository issuedCouponRepository; - private final CouponReadService couponReadService; - // failed_coupon_log에 coupon_id가 없고, used_map에 있는 것 중에서, 쿠폰 유효 기간이 지난 것을 삭제해야함 - // failed_coupon_log에 채워지기 전에 이 스케줄러가 돌아가지고 existsByCouponId가 false를 리턴하면 어떡하지..? + /** + * 쿠폰 유효기간이 지난 것을 삭제 + * 유효기간이 지난 쿠폰을 확인하면서, couponRestrictionManager에 캐싱된 데이터도 함께 삭제 + */ @Scheduled(fixedDelay = 60 * 1_000) @Transactional - public void processCouponWithdraw() { - for (Long usedCouponId : couponRestrictionManager.getNotUsableCoupons()) { - Coupon coupon = couponReadService.findById(usedCouponId); - LocalDateTime now = LocalDateTime.now(); - if (coupon.getExpiredTime().isBefore(now)) { - if (!failedCouponLogRepository.existsByCouponId(usedCouponId)) { - issuedCouponRepository.deleteByCouponId(usedCouponId); - couponRestrictionManager.removeNotUsableCoupon(usedCouponId); - } - } + 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 index 47b664c97..77eff2338 100644 --- a/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/FailedCouponScheduler.java @@ -18,9 +18,13 @@ public class FailedCouponScheduler { private final FailedCouponLogRepository failedCouponLogRepository; private final LockedCouponService lockedCouponService; + /** + * 쿠폰 사용 시 실패했는데, 재시도 3번 조차 실패한 쿠폰 사용 로직에 대한 처리를 맡는 스케줄러 + * 5초마다 돌게 되는데, 계속 실패하는 거 때문에 무한 루프 타지 않도록 큐처럼 선입 선출로 처리함. + */ @Scheduled(fixedDelay = 5000) @Transactional - public void processFailedCouponLog() { + public void scheduleFailedCouponLog() { long count = failedCouponLogRepository.count(); while (count-- > 0) { Optional failedCouponLogOptional = failedCouponLogRepository.findFirst(); From bd1d3710a61284bff5a3c4a3bb8ff0f039f7fd4f Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 21:41:59 +0900 Subject: [PATCH 31/33] =?UTF-8?q?fix:=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/issuedcoupon/service/LockedCouponService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 6b82676e1..063bbc26c 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/service/LockedCouponService.java @@ -31,7 +31,7 @@ public class LockedCouponService { */ @CouponIssueLock(key = "#eventId") public long increaseIssueCount(Long eventId, CouponIssueRequest request, Consumer consumer) { - validateRedundantIssue(eventId, consumer.getId(), request.couponId()); + validateRedundantIssue(eventId, consumer.getId()); Coupon coupon = couponReadService.findById(request.couponId()); if (coupon.getIssuedCount().equals(coupon.getMaximumIssued())) { couponRestrictionManager.addNotIssuableCoupon(coupon.getId()); @@ -67,7 +67,7 @@ public void decreaseUsedCount(Long couponId, Long issuedCouponId) { issuedCoupon.decreaseUsedCount(); } - private void validateRedundantIssue(Long eventId, Long consumerId, Long couponId) { + private void validateRedundantIssue(Long eventId, Long consumerId) { if (issuedCouponReadService.existsByConsumerIdAndEventId(consumerId, eventId)) { throw SINGLE_COUPON_AVAILABLE_PER_EVENT.baseException(); } From 3047b0a6541384198eb879cffbe5491fb54355c1 Mon Sep 17 00:00:00 2001 From: kariskan Date: Sat, 22 Jun 2024 21:45:30 +0900 Subject: [PATCH 32/33] =?UTF-8?q?fix:=20@Param=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issuedcoupon/repository/IssuedCouponRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 345dd012b..154e8c541 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -21,7 +21,7 @@ public interface IssuedCouponRepository extends JpaRepository Date: Tue, 2 Jul 2024 01:31:05 +0900 Subject: [PATCH 33/33] =?UTF-8?q?style:=20indent=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issuedcoupon/repository/IssuedCouponRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 154e8c541..a2cbc98f1 100644 --- a/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/issuedcoupon/repository/IssuedCouponRepository.java @@ -25,9 +25,9 @@ public interface IssuedCouponRepository extends JpaRepository