Skip to content

Commit

Permalink
[1단계 - 복제 지연] 몰리(김지민) 미션 제출합니다. (#41)
Browse files Browse the repository at this point in the history
* docs(STEP1.md): 요구사항 분석

* feat(Name): 쿠폰 이름 VO 구현

* feat(DiscountAmountTest): 쿠폰 할인 금액 VO 구현

* feat(MinimumOrderAmount): 최소 주문 금액 VO 구현

* refactor(DiscountAmount, Name): VO record로 변경

* feat(Category): Category Enum 구현

* feat(DiscountRate): 할인율 VO 구현

* feat(IssuancePeriod): 발급 기간 VO 구현

* feat(Coupon): Coupon 엔티티 구현

* feat(Member): Member 엔티티 구현

* feat(BaseTimeEntity): BaseTimeEntity 구현

* feat(ExpirationDate): 회원 쿠폰 만료일 VO 구현

* feat(MemberCoupon): MemberCoupon 엔티티 구현

* feat(/src): 패키지 구조 개

* refactor(/domain): 변수명을 명확하게 변경

* fix(application.yml): hbm2ddl 설정을 update로 변경

* feat(CouponService): 쿠폰 생성, 조회 구현

* chore(/datasource): DataSource 설정

* feat(CouponFixture): 테스트용 픽스처 생성

* test(CouponServiceTest): 쿠폰 복제 지연 테스트

* refactor(DataSourceConfiguration): 상수 추출

* feat(DiscountRate): 할인율을 Integer로 변경

* feat(DiscountRate): 할인율을 Integer로 변경

* refactor(DiscountRate): 할인율을 도메인의 책임으로

* feat(TransactionRouter): WriteDB로 요청을 보내는 객체 추가

* feat(CouponService): read 실패 시 write로 요청을 보내도록

* refactor(/src): 패키지 개선

* refactor(IssuancePeriod): 발급 시간 확인 로직 개선

* refactor(IssuancePeriod): @DisplayName 추가

* refactor(ExpirationDate): 만료 기간 확인 로직 개선

* refactor(DiscountAmount, MinimumOrderAmount): 금액 타입을 Long으로 일치

* fix: 물리 네이밍 방식을 카멜 케이스를 언더스코어로 변경해주는 전략으로 변경

* refactor(/domain): 금액 출력 형식을 상수와 포맷 문자열을 사용하여 개선

* refactor(/domain): 아이디 생성 전략을 IDENTITY로 변

* refactor(DataSourceConfiguration): Datasource 빈 주입 방식 변경

* refactor(/domain): 객체 타입 null 체크 추가
  • Loading branch information
jminkkk authored Oct 26, 2024
1 parent c9c48d5 commit 5219f12
Show file tree
Hide file tree
Showing 28 changed files with 776 additions and 2 deletions.
48 changes: 48 additions & 0 deletions docs/STEP1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 요구사항 분석
- [x] 도메인 설계
- [x] 엔티티 설계 및 구현
- [x] 쿠폰 생성 구현
- [x] 쿠폰 조회 기능 구현
- [x] 복제 지연 이슈 확인
- [x] 복제 지연 해결

## 도메인 설계

### 쿠폰

회원에게 발급하는 쿠폰의 이름, 할인 금액, 최소 주문 금액 등 쿠폰의 설정에 해당하는 내용을 관리한다.

- 이름
- 이름은 반드시 존재해야 한다.
- 이름의 길이는 최대 30자 이하여야 한다.
- 할인 금액
- 할인 금액은 1,000원 이상이어야 한다.
- 할인 금액은 10,000원 이하여야 한다.
- 할인 금액은 500원 단위로 설정할 수 있다.
- 할인율 제약
- 할인율은 할인금액 / 최소 주문 금액 식을 사용하여 계산하며, 소수점은 버림 한다.
- 예를 들어, 최소 주문 금액이 30,000원일 때 할인 금액이 1,000원이라면 쿠폰의 할인율은 3%(1000 / 30000 = 3.333...%)가 된다.
- 할인율은 3% 이상이어야 한다.
- 할인율은 20% 이하여야 한다.
- 최소 주문 금액
- 최소 주문 금액은 5,000원 이상이어야 한다.
- 최소 주문 금액은 100,000원 이하여야 한다.
- 카테고리
- 서비스의 카테고리는 다음과 같이 네 종류가 있고, 이 중 하나의 카테고리만 선택할 수 있다.
- 패션, 가전, 가구, 식품
- 발급 기간
- 시작일은 종료일보다 이전이어야 한다. 시작일과 종료일이 같다면, 해당 일에만 발급할 수 있는 쿠폰이 된다.
- 시작일 00:00:00.000000 부터 발급할 수 있다.
- 종료일 23:59:59.999999 까지 발급할 수 있다.

### 회원 쿠폰
회원에게 발급된 쿠폰 한 장을 관리한다. 다음 정보를 포함한다.

- 회원 쿠폰 PK
- 쿠폰 PK
- 회원 PK
- 사용 여부
- 발급 일시
- 만료 일시
- 회원에게 발급된 쿠폰은 발급일 포함 7일 동안 사용 가능하다.
- 만료 일의 23:59:59.999999 까지 사용할 수 있다.
31 changes: 31 additions & 0 deletions src/main/java/coupon/coupon/application/CouponService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package coupon.coupon.application;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import coupon.coupon.domain.Coupon;
import coupon.coupon.domain.CouponRepository;
import coupon.infrastructure.datasource.TransactionRouter;
import lombok.AllArgsConstructor;

@Service
@AllArgsConstructor
public class CouponService {

private final CouponRepository couponRepository;
private final TransactionRouter transactionRouter;

public void create(final Coupon coupon) {
couponRepository.save(coupon);
}

@Transactional(readOnly = true)
public Coupon getCoupon(final Long id) {
return couponRepository.findById(id)
.orElseGet(() -> getCouponFromWrite(id));
}

public Coupon getCouponFromWrite(final Long id) {
return transactionRouter.routeWrite(() -> couponRepository.fetchById(id));
}
}
8 changes: 8 additions & 0 deletions src/main/java/coupon/coupon/domain/Category.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package coupon.coupon.domain;

public enum Category {
PASSION,
HOME_APPLIANCE,
FURNITURE,
FOOD,
}
63 changes: 63 additions & 0 deletions src/main/java/coupon/coupon/domain/Coupon.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package coupon.coupon.domain;

import java.time.LocalDate;

import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import coupon.infrastructure.audit.BaseTimeEntity;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "coupon")
public class Coupon extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

@Column(name = "name", nullable = false)
private Name name;

@Column(name = "discount_mount", nullable = false)
@Embedded
private DiscountAmount discountAmount;

@Column(name = "discount_rate", nullable = false)
@Embedded
private DiscountRate discountRate;

@Column(name = "minimum_order_amount", nullable = false)
@Embedded
private MinimumOrderAmount minimumOrderAmount;

@Column(name = "issuance_period", nullable = false)
@Embedded
private IssuancePeriod issuancePeriod;

public Coupon(
final String name,
final Long discountAmount,
final Long minimumOrderAmount,
final LocalDate issueStartDate,
final LocalDate issueEndDate
) {
this.name = new Name(name);
this.discountAmount = new DiscountAmount(discountAmount);
this.discountRate = DiscountRate.from(discountAmount, minimumOrderAmount);
this.minimumOrderAmount = new MinimumOrderAmount(minimumOrderAmount);
this.issuancePeriod = new IssuancePeriod(issueStartDate, issueEndDate);
}
}
14 changes: 14 additions & 0 deletions src/main/java/coupon/coupon/domain/CouponRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package coupon.coupon.domain;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CouponRepository extends JpaRepository<Coupon, Long> {

default Coupon fetchById(Long id) {
return findById(id).orElseThrow(() -> new IllegalArgumentException("해당하는 쿠폰이 없습니다. id: " + id));
}

Optional<Coupon> findById(Long id);
}
35 changes: 35 additions & 0 deletions src/main/java/coupon/coupon/domain/DiscountAmount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package coupon.coupon.domain;

import jakarta.persistence.Embeddable;

@Embeddable
public record DiscountAmount(Long discountAmount) {

private static final Long MINIMUM_DISCOUNT_AMOUNT = 1_000L;
private static final Long MAXIMUM_DISCOUNT_AMOUNT = 10_000L;
private static final Long DISCOUNT_AMOUNT_UNIT = 500L;

public DiscountAmount {
validateDiscountAmountNull(discountAmount);
validateDiscountAmountRange(discountAmount);
validateDiscountUnit(discountAmount);
}

private void validateDiscountAmountNull(final Long discountAmount) {
if (discountAmount == null) {
throw new IllegalArgumentException("할인 금액은 null이 될 수 없습니다.");
}
}

private void validateDiscountAmountRange(final Long discountAmount) {
if (discountAmount > MAXIMUM_DISCOUNT_AMOUNT || discountAmount < MINIMUM_DISCOUNT_AMOUNT) {
throw new IllegalArgumentException(String.format("할인 금액은 %,d원 이상 %,d원 이하만 가능합니다.", MINIMUM_DISCOUNT_AMOUNT, MAXIMUM_DISCOUNT_AMOUNT));
}
}

private void validateDiscountUnit(final Long discountAmount) {
if (discountAmount % DISCOUNT_AMOUNT_UNIT != 0) {
throw new IllegalArgumentException(String.format("할인 금액은 %,d원 단위로만 가능합니다.", DISCOUNT_AMOUNT_UNIT));
}
}
}
31 changes: 31 additions & 0 deletions src/main/java/coupon/coupon/domain/DiscountRate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package coupon.coupon.domain;

import jakarta.persistence.Embeddable;

@Embeddable
public record DiscountRate(Integer discountRate) {

private static final Integer MINIMUM_DISCOUNT_RATE = 3;
private static final Integer MAXIMUM_DISCOUNT_RATE = 20;

public DiscountRate {
validateDiscountRateNull(discountRate);
validateRateRange(discountRate);
}

private void validateDiscountRateNull(final Integer discountRate) {
if (discountRate == null) {
throw new IllegalArgumentException("할인율은 null이 될 수 없습니다.");
}
}

public static DiscountRate from(final Long discountAmount, final Long minimumOrderAmount) {
return new DiscountRate((int) ((double) discountAmount / minimumOrderAmount * 100));
}

public void validateRateRange(final Integer discountRate) {
if (discountRate < MINIMUM_DISCOUNT_RATE || discountRate > MAXIMUM_DISCOUNT_RATE) {
throw new IllegalArgumentException(String.format("할인율은 %,d%% 이상 %,d%% 이하의 값이어야 합니다.", MINIMUM_DISCOUNT_RATE, MAXIMUM_DISCOUNT_RATE));
}
}
}
35 changes: 35 additions & 0 deletions src/main/java/coupon/coupon/domain/IssuancePeriod.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package coupon.coupon.domain;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

import jakarta.persistence.Embeddable;

@Embeddable
public record IssuancePeriod(LocalDate startDate, LocalDate endDate) {

public IssuancePeriod {
validateIssuancePeriodNull(startDate, endDate);
}

public boolean canIssue(final LocalDateTime dateTime) {
LocalDateTime startDateTime = getStartDateTime();
LocalDateTime endDateTime = getEndDateTime();
return !dateTime.isBefore(startDateTime) && !dateTime.isAfter(endDateTime);
}

private void validateIssuancePeriodNull(final LocalDate startDate, final LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("쿠폰 발행 시작일 또는 종료일은 null이 될 수 없습니다.");
}
}

public LocalDateTime getStartDateTime() {
return startDate.atStartOfDay();
}

public LocalDateTime getEndDateTime() {
return endDate.atTime(LocalTime.MAX);
}
}
27 changes: 27 additions & 0 deletions src/main/java/coupon/coupon/domain/MinimumOrderAmount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package coupon.coupon.domain;

import jakarta.persistence.Embeddable;

@Embeddable
public record MinimumOrderAmount(Long minimumOrderAmount) {

private static final Long MINIMUM_ORDER_AMOUNT = 5_000L;
private static final Long MAXIMUM_ORDER_AMOUNT = 100_000L;

public MinimumOrderAmount {
validateMinimumOrderAmountNull(minimumOrderAmount);
validateAmount(minimumOrderAmount);
}

private void validateMinimumOrderAmountNull(final Long minimumOrderAmount) {
if (minimumOrderAmount == null) {
throw new IllegalArgumentException("최소 주문 금액은 null이 될 수 없습니다.");
}
}

private void validateAmount(final Long minimumOrderAmount) {
if (minimumOrderAmount < MINIMUM_ORDER_AMOUNT || minimumOrderAmount > MAXIMUM_ORDER_AMOUNT) {
throw new IllegalArgumentException(String.format("최소 주문 금액은 %,d원 이상 %,d원 이하이어야 합니다.", MINIMUM_ORDER_AMOUNT, MAXIMUM_ORDER_AMOUNT));
}
}
}
26 changes: 26 additions & 0 deletions src/main/java/coupon/coupon/domain/Name.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package coupon.coupon.domain;

import jakarta.persistence.Embeddable;

@Embeddable
public record Name(String name) {

private static final int MAX_NAME_LENGTH = 30;

public Name {
validateNameNull(name);
validateNameLength(name);
}

private void validateNameNull(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("이름은 null이 될 수 없습니다.");
}
}

private void validateNameLength(final String name) {
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("이름은 최대 30자 이하여야 합니다.");
}
}
}
27 changes: 27 additions & 0 deletions src/main/java/coupon/infrastructure/audit/BaseTimeEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package coupon.infrastructure.audit;

import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import lombok.Getter;

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {

@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column(nullable = false)
private LocalDateTime modifiedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package coupon.infrastructure.audit;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
}
Loading

0 comments on commit 5219f12

Please sign in to comment.