diff --git a/.gitignore b/.gitignore index df1daf96..76b45229 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -README.md .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/README.md b/README.md new file mode 100644 index 00000000..b676e157 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +## 기능 요구 사항 + +### 도메인 + +- [x] 쿠폰 +- [x] 이름 + - 최대 30자 +- [ ] 할인 + - [x] 할인 금액 + - 할인 금액은 1,000원 이상 + - 할인 금액은 10,000원 이하 + - 할인 금액은 500원 단위 + - [ ] 할인율 + - 할인율은 `할인금액 / 최소 주문 금액`, 소수점은 버림 + - 할인율은 3% 이상 + - 할인율은 20% 이하 +- [x] 최소 주문 금액 + - 최소 주문 금액은 5,000원 이상 + - 최소 주문 금액은 100,000원 이하 +- [ ] 카테고리 + - 패션, 가전, 가구, 식품 +- [ ] 발급 기간 + - 시작일은 종료일보다 이전 + - 시작일 00:00:00.000000 부터 발급 + - 종료일 23:59:59.999999 까지 발급 + + diff --git a/src/main/java/coupon/config/DataSourceConfig.java b/src/main/java/coupon/config/DataSourceConfig.java new file mode 100644 index 00000000..35443cd1 --- /dev/null +++ b/src/main/java/coupon/config/DataSourceConfig.java @@ -0,0 +1,45 @@ +package coupon.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; + +import javax.sql.DataSource; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class DataSourceConfig { + + @Bean + @ConfigurationProperties(prefix = "coupon.datasource.writer") + public DataSource writerDataSource() { + return DataSourceBuilder.create().build(); + } + + @Bean + @ConfigurationProperties(prefix = "coupon.datasource.reader") + public DataSource readerDataSource() { + return DataSourceBuilder.create().build(); + } + + @Bean + public DataSource routingDataSource(DataSource writerDataSource, DataSource readerDataSource) { + ReadOnlyDataSourceRouter routingDataSource = new ReadOnlyDataSourceRouter(); + Map targetDataSources = new HashMap<>(); + targetDataSources.put(DataSourceType.WRITER, writerDataSource); + targetDataSources.put(DataSourceType.READER, readerDataSource); + routingDataSource.setTargetDataSources(targetDataSources); + routingDataSource.setDefaultTargetDataSource(writerDataSource); + return routingDataSource; + } + + @Primary + @Bean + public DataSource dataSource() { + return new LazyConnectionDataSourceProxy(routingDataSource(writerDataSource(), readerDataSource())); + } +} diff --git a/src/main/java/coupon/config/DataSourceType.java b/src/main/java/coupon/config/DataSourceType.java new file mode 100644 index 00000000..5864119a --- /dev/null +++ b/src/main/java/coupon/config/DataSourceType.java @@ -0,0 +1,6 @@ +package coupon.config; + +public enum DataSourceType { + + READER, WRITER +} diff --git a/src/main/java/coupon/config/ReadOnlyDataSourceRouter.java b/src/main/java/coupon/config/ReadOnlyDataSourceRouter.java new file mode 100644 index 00000000..ed9ccc10 --- /dev/null +++ b/src/main/java/coupon/config/ReadOnlyDataSourceRouter.java @@ -0,0 +1,19 @@ +package coupon.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +public class ReadOnlyDataSourceRouter extends AbstractRoutingDataSource { + + @Override + protected Object determineCurrentLookupKey() { + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + log.info("Reader is running"); + return DataSourceType.READER; + } + log.info("Writer is running"); + return DataSourceType.WRITER; + } +} diff --git a/src/main/java/coupon/domain/Coupon.java b/src/main/java/coupon/domain/Coupon.java new file mode 100644 index 00000000..57ab9586 --- /dev/null +++ b/src/main/java/coupon/domain/Coupon.java @@ -0,0 +1,30 @@ +package coupon.domain; + +public class Coupon { + + private final Long id; + private final DiscountAmount discountAmount; + private final MinimumOrderPrice minimumOrderPrice; + + public Coupon(int discountAmount, int minimumOrderPrice) { + this(null, discountAmount, minimumOrderPrice); + } + + public Coupon(Long id, int discountAmount, int minimumOrderPrice) { + this.id = id; + this.discountAmount = new DiscountAmount(discountAmount); + this.minimumOrderPrice = new MinimumOrderPrice(minimumOrderPrice); + } + + public Long getId() { + return id; + } + + public int getDiscountAmount() { + return discountAmount.getAmount(); + } + + public int getMinimumOrderPrice() { + return minimumOrderPrice.getAmount(); + } +} diff --git a/src/main/java/coupon/domain/DiscountAmount.java b/src/main/java/coupon/domain/DiscountAmount.java new file mode 100644 index 00000000..645f9d9f --- /dev/null +++ b/src/main/java/coupon/domain/DiscountAmount.java @@ -0,0 +1,32 @@ +package coupon.domain; + +public class DiscountAmount { + + private static final int MIN_DISCOUNT_AMOUNT_RANGE = 1000; + private static final int MAX_DISCOUNT_AMOUNT_RANGE = 10000; + private static final int DISCOUNT_AMOUNT_UNIT = 500; + + private final int amount; + + public DiscountAmount(int amount) { + validateRange(amount); + validateUnit(amount); + this.amount = amount; + } + + private void validateRange(int amount) { + if (amount < MIN_DISCOUNT_AMOUNT_RANGE || amount > MAX_DISCOUNT_AMOUNT_RANGE) { + throw new IllegalArgumentException("유효하지 않은 할인 금액입니다."); + } + } + + private void validateUnit(int amount) { + if (amount % DISCOUNT_AMOUNT_UNIT != 0) { + throw new IllegalArgumentException("유효하지 않은 할인 금액 단위입니다."); + } + } + + public int getAmount() { + return amount; + } +} diff --git a/src/main/java/coupon/domain/MinimumOrderPrice.java b/src/main/java/coupon/domain/MinimumOrderPrice.java new file mode 100644 index 00000000..b6d4a9f6 --- /dev/null +++ b/src/main/java/coupon/domain/MinimumOrderPrice.java @@ -0,0 +1,24 @@ +package coupon.domain; + +public class MinimumOrderPrice { + + private static final int MIN_AMOUNT = 5000; + private static final int MAX_AMOUNT = 100_000; + + private final int amount; + + public MinimumOrderPrice(int amount) { + validateAmount(amount); + this.amount = amount; + } + + private void validateAmount(int amount) { + if (amount < MIN_AMOUNT || amount > MAX_AMOUNT) { + throw new IllegalArgumentException("유효하지 않은 최소 주문 금액입니다."); + } + } + + public int getAmount() { + return amount; + } +} diff --git a/src/main/java/coupon/domain/Name.java b/src/main/java/coupon/domain/Name.java new file mode 100644 index 00000000..229df903 --- /dev/null +++ b/src/main/java/coupon/domain/Name.java @@ -0,0 +1,19 @@ +package coupon.domain; + +public class Name { + + private static final int MAX_NAME_LENGTH = 30; + + private final String name; + + public Name(String name) { + validateLength(name); + this.name = name; + } + + private void validateLength(String name) { + if (name.length() > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("최대 이름 길이를 초과합니다."); + } + } +} diff --git a/src/main/java/coupon/entity/CouponEntity.java b/src/main/java/coupon/entity/CouponEntity.java new file mode 100644 index 00000000..25a2bcef --- /dev/null +++ b/src/main/java/coupon/entity/CouponEntity.java @@ -0,0 +1,35 @@ +package coupon.entity; + +import coupon.domain.Coupon; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "coupon") +public class CouponEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private int discountAmount; + private int minimumOrderPrice; + + public CouponEntity(int discountAmount, int minimumOrderPrice) { + this.discountAmount = discountAmount; + this.minimumOrderPrice = minimumOrderPrice; + } + + public static CouponEntity from(int discountAmount, int minimumOrderPrice) { + return new CouponEntity(discountAmount, minimumOrderPrice); + } + + public Coupon toCoupon() { + return new Coupon(id, discountAmount, minimumOrderPrice); + } +} diff --git a/src/main/java/coupon/repository/CouponRepository.java b/src/main/java/coupon/repository/CouponRepository.java new file mode 100644 index 00000000..c0deeacc --- /dev/null +++ b/src/main/java/coupon/repository/CouponRepository.java @@ -0,0 +1,7 @@ +package coupon.repository; + +import coupon.entity.CouponEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponRepository extends JpaRepository { +} diff --git a/src/main/java/coupon/service/CouponService.java b/src/main/java/coupon/service/CouponService.java new file mode 100644 index 00000000..ad9a2994 --- /dev/null +++ b/src/main/java/coupon/service/CouponService.java @@ -0,0 +1,37 @@ +package coupon.service; + + +import coupon.domain.Coupon; +import coupon.entity.CouponEntity; +import coupon.repository.CouponRepository; +import coupon.support.TransactionSupporter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CouponService { + + private final CouponRepository couponRepository; + private final TransactionSupporter transactionSupporter; + + public Coupon create(Coupon coupon) { + CouponEntity couponEntity = CouponEntity.from(coupon.getDiscountAmount(), coupon.getMinimumOrderPrice()); + CouponEntity savedCoupon = couponRepository.save(couponEntity); + return savedCoupon.toCoupon(); + } + + @Transactional(readOnly = true) + public Coupon getCoupon(long id) { + CouponEntity couponEntity = couponRepository.findById(id) + .orElse(getCouponRetry(id)); + + return couponEntity.toCoupon(); + } + + private CouponEntity getCouponRetry(long id) { + return transactionSupporter.executeNewTransaction(() -> couponRepository.findById(id) + .orElseThrow(IllegalArgumentException::new)); + } +} diff --git a/src/main/java/coupon/support/TransactionSupporter.java b/src/main/java/coupon/support/TransactionSupporter.java new file mode 100644 index 00000000..823830e5 --- /dev/null +++ b/src/main/java/coupon/support/TransactionSupporter.java @@ -0,0 +1,17 @@ +package coupon.support; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.function.Supplier; + +@Component +public class TransactionSupporter { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public T executeNewTransaction(Supplier method) { + return method.get(); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 54f7c419..0c6e4015 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,9 +10,9 @@ spring: default_batch_fetch_size: 128 id.new_generator_mappings: true format_sql: true - show_sql: false + show_sql: true use_sql_comments: true - hbm2ddl.auto: validate + hbm2ddl.auto: update check_nullability: true query.in_clause_parameter_padding: true open-in-view: false diff --git a/src/test/java/coupon/CouponServiceTest.java b/src/test/java/coupon/CouponServiceTest.java new file mode 100644 index 00000000..d94ed25e --- /dev/null +++ b/src/test/java/coupon/CouponServiceTest.java @@ -0,0 +1,23 @@ +package coupon; + +import coupon.domain.Coupon; +import coupon.service.CouponService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class CouponServiceTest { + + @Autowired + private CouponService couponService; + + @Test + void 복제지연테스트() { + Coupon coupon = couponService.create(new Coupon(1000, 10000)); + Coupon savedCoupon = couponService.getCoupon(coupon.getId()); + assertThat(savedCoupon).isNotNull(); + } +} diff --git a/src/test/java/coupon/domain/DiscountAmountTest.java b/src/test/java/coupon/domain/DiscountAmountTest.java new file mode 100644 index 00000000..17e6e014 --- /dev/null +++ b/src/test/java/coupon/domain/DiscountAmountTest.java @@ -0,0 +1,28 @@ +package coupon.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DiscountAmountTest { + + @DisplayName("할인 금액의 범위를 넘거가면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(ints = {500, 10500}) + void throwException_outRange(int amount) { + assertThatThrownBy(() -> new DiscountAmount(amount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("유효하지 않은 할인 금액입니다."); + } + + @DisplayName("유효하지 않은 단위의 할인금액인 경우 예외가 발생한다.") + @ParameterizedTest + @ValueSource(ints = {1001, 9999}) + void throwException_invalidUnit(int amount) { + assertThatThrownBy(() -> new DiscountAmount(amount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("유효하지 않은 할인 금액 단위입니다."); + } +} diff --git a/src/test/java/coupon/domain/MinimumOrderPriceTest.java b/src/test/java/coupon/domain/MinimumOrderPriceTest.java new file mode 100644 index 00000000..6d1e1ecc --- /dev/null +++ b/src/test/java/coupon/domain/MinimumOrderPriceTest.java @@ -0,0 +1,19 @@ +package coupon.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MinimumOrderPriceTest { + + @DisplayName("유효하지 않은 최소 주문 금액의 경우 예외가 발생한다.") + @ParameterizedTest + @ValueSource(ints = {4999, 100001}) + void throwException_invalidAmount(int amount) { + assertThatThrownBy(() -> new MinimumOrderPrice(amount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("유효하지 않은 최소 주문 금액입니다."); + } +} diff --git a/src/test/java/coupon/domain/NameTest.java b/src/test/java/coupon/domain/NameTest.java new file mode 100644 index 00000000..21fe2db9 --- /dev/null +++ b/src/test/java/coupon/domain/NameTest.java @@ -0,0 +1,24 @@ +package coupon.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NameTest { + + @DisplayName("최대 길이를 초과하는 경우 예외가 발생한다.") + @Test + void fail_invalidateNameLength() { + String couponName = "카피 반갑습니다 쿠폰. 유효기간은 오늘까지입니다. 감사요"; + + assertAll( + () -> assertThat(couponName.length()).isEqualTo(31), + () -> assertThatThrownBy(() -> new Name(couponName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("최대 이름 길이를 초과합니다.") + ); + } +}