diff --git a/service/coupon-api/docs/CouponApiTest.http b/service/coupon-api/docs/CouponApiTest.http index c19ba5b..32b9bea 100644 --- a/service/coupon-api/docs/CouponApiTest.http +++ b/service/coupon-api/docs/CouponApiTest.http @@ -4,9 +4,11 @@ Content-Type: application/json { "name": "여름 할인 쿠폰", - "status": "ISSUABLE", - "quantity": 100 + "status": "AVAILABLE", + "quantity": 1, + "issueStartAt": "2025-01-11T09:00:00", + "issueEndAt": "2025-01-11T15:15:59" } ### 쿠폰 발급 -POST http://localhost:8080/coupon/issue/1?user-id=1 +POST http://localhost:8080/coupon/issue/1?user-id=1372 diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java new file mode 100644 index 0000000..d763a40 --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponCreateService.java @@ -0,0 +1,26 @@ +package com.couponify.couponapi.application; + +import com.couponify.couponapi.presentation.request.CouponCreateRequest; +import com.couponify.coupondomain.domain.coupon.Coupon; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j(topic = "CouponCreateService") +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CouponCreateService { + + private final CouponRepository couponRepository; + + @Transactional + public Long create(CouponCreateRequest couponCreateRequest) { + final Coupon coupon = CouponCreateRequest.toDomain(couponCreateRequest); + final Coupon savedCoupon = couponRepository.save(coupon); + return savedCoupon.getId(); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java new file mode 100644 index 0000000..e1de31d --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponExpireService.java @@ -0,0 +1,45 @@ +package com.couponify.couponapi.application; + +import com.couponify.couponapi.common.CouponPrefix; +import com.couponify.coupondomain.domain.coupon.Coupon; +import com.couponify.coupondomain.domain.coupon.CouponCache; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j(topic = "CouponExpireService") +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponExpireService { + + private final CouponRepository couponRepository; + private final RedissonClient redissonClient; + + @Transactional + public void expire() { + List expiredCoupons = couponRepository.findExpiredCoupons(LocalDateTime.now()); + if (expiredCoupons.isEmpty()) { + return; + } + expiredCoupons.forEach(Coupon::expire); + deleteCouponCache(expiredCoupons.stream().map(Coupon::getId).toList()); + log.info("Expired {} coupons", expiredCoupons.size()); + } + + private void deleteCouponCache(List couponIds) { + couponIds.forEach(couponId -> deleteCouponCache(couponId)); + } + + private void deleteCouponCache(Long couponId) { + RMap couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO); + couponInfo.remove(couponId); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java new file mode 100644 index 0000000..093379f --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponIssueService.java @@ -0,0 +1,174 @@ +package com.couponify.couponapi.application; + + +import static com.couponify.couponapi.common.CouponPrefix.COUPON_INFO; +import static com.couponify.couponapi.common.CouponPrefix.COUPON_ISSUER; + +import com.couponify.couponapi.exception.CouponErrorCode; +import com.couponify.couponapi.exception.CouponException; +import com.couponify.coupondomain.domain.coupon.Coupon; +import com.couponify.coupondomain.domain.coupon.CouponCache; +import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; +import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMap; +import org.redisson.api.RMapCache; +import org.redisson.api.RTransaction; +import org.redisson.api.RedissonClient; +import org.redisson.api.TransactionOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j(topic = "CouponIssueService") +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponIssueService { + + private static final int QUANTITY_TO_ISSUE_COUPON = 1; + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final RedissonClient redissonClient; + + @Value("${cache.coupon.expiration.hours}") + private Long couponExpirationHours; + + public void cacheCouponIssuance(Long couponId, Long userId) { + RTransaction transaction = redissonClient.createTransaction(TransactionOptions.defaults()); + try { + CouponCache couponCache = getCouponCache(transaction, couponId); + + checkUserAlreadyIssued(transaction, couponId, userId); + couponCache.issue(userId, QUANTITY_TO_ISSUE_COUPON); + addIssuer(transaction, couponId, userId); + updateCouponCache(transaction, couponId, couponCache); + + transaction.commit(); + + setCouponCacheTTL(couponId, couponCache); + } catch (Exception e) { + transaction.rollback(); + throw new CouponException(CouponErrorCode.TRANSACTION_COMMIT_FAILED, e.getMessage()); + } + } + + @Transactional + public void persistCouponIssuance() { + RTransaction transaction = redissonClient.createTransaction(TransactionOptions.defaults()); + try { + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + if (couponIssuer.isEmpty()) { + return; + } + processCouponIssuance(transaction, couponIssuer); + transaction.commit(); + } catch (Exception e) { + transaction.rollback(); + throw new CouponException(CouponErrorCode.TRANSACTION_COMMIT_FAILED, e.getMessage()); + } + } + + public Set getIssuedCouponIds() { + RMap> couponIssuer = redissonClient.getMap(COUPON_ISSUER); + return couponIssuer.keySet(); + } + + private CouponCache getCouponCache(RTransaction transaction, Long couponId) { + RMapCache couponInfo = transaction.getMapCache(COUPON_INFO); + CouponCache couponCache = couponInfo.get(couponId); + + if (couponCache == null) { + Coupon coupon = getCoupon(couponId); + Set issuerIds = getIssuerIds(coupon); + return CouponCache.of(coupon, issuerIds); + } + return couponCache; + } + + private Set getIssuerIds(Coupon coupon) { + return new HashSet<>(issuedCouponRepository.findUserIdsByCoupon(coupon)); + } + + private void checkUserAlreadyIssued(RTransaction transaction, Long couponId, Long userId) { + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + Set issuers = couponIssuer.get(couponId); + + if (issuers == null) { + issuers = new HashSet<>(); + couponIssuer.put(couponId, issuers); + } else if (issuers.contains(userId)) { + throw new CouponException(CouponErrorCode.COUPON_ALREADY_ISSUED); + } + } + + private void addIssuer(RTransaction transaction, Long couponId, Long userId) { + RMap> couponIssuer = transaction.getMap(COUPON_ISSUER); + Set issuers = couponIssuer.get(couponId); + issuers.add(userId); + couponIssuer.put(couponId, issuers); + } + + private void updateCouponCache(RTransaction transaction, Long couponId, + CouponCache couponCache) { + RMapCache couponInfo = transaction.getMapCache(COUPON_INFO); + couponInfo.put(couponId, couponCache); + } + + private void setCouponCacheTTL(Long couponId, CouponCache couponCache) { + RMapCache couponInfo = redissonClient.getMapCache( + COUPON_INFO); + couponInfo.expire(Duration.ofHours(couponExpirationHours)); + } + + private void processCouponIssuance( + RTransaction transaction, + RMap> couponIssuer + ) { + List issuedCoupons = new ArrayList<>(); + Set issuedCouponIds = couponIssuer.keySet(); + + // 쿠폰 ID별 IssuedCoupon 생성 및 저장 + for (Long issuedCouponId : issuedCouponIds) { + Set issuers = couponIssuer.get(issuedCouponId); + Coupon coupon = getCoupon(issuedCouponId); + issuedCoupons.addAll(createIssuedCoupon(issuers, coupon)); + + coupon.decreaseQuantity(issuers.size()); + log.info("{} 쿠폰의 수량을 {}개 차감합니다.", issuedCouponId, issuers.size()); + + updateCouponInfoWithIssuer(transaction, issuedCouponId, issuers); + // 발급 쿠폰 캐시 삭제 + couponIssuer.remove(issuedCouponId); + } + issuedCouponRepository.saveAll(issuedCoupons); + log.info("{}개의 쿠폰 발급을 완료했습니다.", issuedCoupons.size()); + } + + private List createIssuedCoupon(Set issuers, Coupon coupon) { + return issuers.stream() + .map(issuer -> IssuedCoupon.of(issuer, coupon)) + .toList(); + } + + private void updateCouponInfoWithIssuer(RTransaction transaction, Long couponId, + Set issuers) { + CouponCache couponCache = getCouponCache(transaction, couponId); + couponCache.addIssuers(issuers); + updateCouponCache(transaction, couponId, couponCache); + } + + private Coupon getCoupon(Long couponId) { + return couponRepository.findById(couponId).orElseThrow( + () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) + ); + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java index 2756bef..3702f47 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponLockService.java @@ -1,38 +1,38 @@ package com.couponify.couponapi.application; -import com.couponify.couponapi.exception.CouponErrorCode; -import com.couponify.couponapi.exception.CouponException; +import static com.couponify.couponapi.common.CouponPrefix.LOCK_COUPON_PREFIX; + +import com.couponify.couponapi.common.RedissonLockManager; import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import java.util.concurrent.TimeUnit; +import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +@Slf4j(topic = "CouponLockService") @Service @RequiredArgsConstructor public class CouponLockService { - private final RedissonClient redissonClient; - private final CouponService couponService; + private final CouponIssueService couponIssueService; private final CouponRepository couponRepository; + private final RedissonLockManager redissonLockManager; + + public void cacheCouponIssuance(Long couponId, Long userId) { + redissonLockManager.executeLock(LOCK_COUPON_PREFIX + couponId, 10, 5, + () -> couponIssueService.cacheCouponIssuance(couponId, userId)); + } + + public void persistCouponIssuance() { + Set issuedCouponIds = couponIssueService.getIssuedCouponIds(); + List lockNames = generateIssuanceLockNames(issuedCouponIds.stream().toList()); + redissonLockManager.executeMultipleLocks(lockNames, 10, 5, 3, + couponIssueService::persistCouponIssuance); + } - public Long issueRLock(Long couponId, Long userId) { - RLock lock = redissonClient.getLock("issue:coupon:" + couponId); - - try { - if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { - return couponService.issue(couponId, userId); - } else { - throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); - } - } catch (InterruptedException e) { - throw new RuntimeException(e); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - } + private List generateIssuanceLockNames(List couponIds) { + return couponIds.stream().map(couponId -> LOCK_COUPON_PREFIX + couponId).toList(); } } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java index 175b87f..3892e0a 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponSchedulerService.java @@ -11,14 +11,23 @@ @RequiredArgsConstructor public class CouponSchedulerService { - private final CouponService couponService; + private final CouponExpireService couponExpireService; + private final CouponIssueService couponIssueService; + @Value("${schedule.use}") private boolean useSchedule; - @Scheduled(cron = "${schedule.cron}") + @Scheduled(cron = "${schedule.cron.expire}") public void expireCoupon() { if (useSchedule) { - couponService.expire(); + couponExpireService.expire(); + } + } + + @Scheduled(cron = "${schedule.cron.issue}") + public void issueCoupon() { + if (useSchedule) { + couponIssueService.persistCouponIssuance(); } } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java b/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java deleted file mode 100644 index 6748e18..0000000 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/application/CouponService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.couponify.couponapi.application; - -import com.couponify.couponapi.exception.CouponErrorCode; -import com.couponify.couponapi.exception.CouponException; -import com.couponify.couponapi.presentation.request.CouponCreateRequest; -import com.couponify.coupondomain.domain.coupon.Coupon; -import com.couponify.coupondomain.domain.coupon.repository.CouponRepository; -import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; -import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; -import java.time.LocalDateTime; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j(topic = "CouponService") -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class CouponService { - - private static final int QUANTITY_TO_ISSUE_COUPON = 1; - private final CouponRepository couponRepository; - private final IssuedCouponRepository issuedCouponRepository; - - @Transactional - public Long create(CouponCreateRequest couponCreateRequest) { - final Coupon coupon = CouponCreateRequest.toDomain(couponCreateRequest); - final Coupon savedCoupon = couponRepository.save(coupon); - return savedCoupon.getId(); - } - - @Transactional - public Long issue(Long couponId, Long userId) { - final Coupon coupon = validateCoupon(couponId); - coupon.issue(QUANTITY_TO_ISSUE_COUPON); - - //TODO User 검증 필요 - - final IssuedCoupon issuedCoupon = IssuedCoupon.of(userId, coupon); - final IssuedCoupon savedIssuedCoupon = issuedCouponRepository.save(issuedCoupon); - - return savedIssuedCoupon.getId(); - } - - @Transactional - public void expire() { - List expiredCoupons = couponRepository.findExpiredCoupons(LocalDateTime.now()); - if (expiredCoupons.isEmpty()) { - return; - } - expiredCoupons.forEach(Coupon::expire); - log.info("Expired {} coupons", expiredCoupons.size()); - } - - private Coupon validateCoupon(Long couponId) { - return couponRepository.findById(couponId).orElseThrow( - () -> new CouponException(CouponErrorCode.COUPON_NOT_FOUND, couponId) - ); - } - -} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java new file mode 100644 index 0000000..62c7c83 --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/CouponPrefix.java @@ -0,0 +1,10 @@ +package com.couponify.couponapi.common; + +public class CouponPrefix { + + public static final String COUPON_INFO = "coupon:info"; + public static final String COUPON_ISSUER = "coupon:issuer"; + public static final String LOCK_COUPON_PREFIX = "lock:coupon:"; + public static final String LOCK_ISSUER_PREFIX = "lock:issuer:"; + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java new file mode 100644 index 0000000..2f88997 --- /dev/null +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/common/RedissonLockManager.java @@ -0,0 +1,74 @@ +package com.couponify.couponapi.common; + +import com.couponify.couponapi.exception.CouponErrorCode; +import com.couponify.couponapi.exception.CouponException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedissonLockManager { + + private final RedissonClient redissonClient; + + public void executeLock( + String lockName, long waitSeconds, long leaseSeconds, Runnable logic) { + RLock lock = redissonClient.getLock(lockName); + try { + if (!lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS)) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } + logic.run(); + } catch (InterruptedException e) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } finally { + lock.unlock(); + } + } + + public void executeMultipleLocks( + List lockNames, long waitSeconds, long leaseSeconds, int retryCount, + Runnable logic) { + for (int attempt = 0; attempt < retryCount; attempt++) { + List locks = new ArrayList<>(); + try { + if (tryAcquireLocks(lockNames, locks, waitSeconds, leaseSeconds)) { + logic.run(); + return; + } + } catch (InterruptedException e) { + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } finally { + releaseLocks(locks); + } + } + throw new CouponException(CouponErrorCode.LOCK_ACQUISITION_FAILED); + } + + private boolean tryAcquireLocks(List lockNames, List locks, long waitSeconds, + long leaseSeconds) throws InterruptedException { + for (String lockName : lockNames) { + RLock lock = redissonClient.getLock(lockName); + locks.add(lock); + + if (!lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS)) { + return false; + } + } + return true; + } + + private void releaseLocks(List locks) { + for (RLock lock : locks) { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + +} diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java b/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java index 8c32d85..c77b296 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/config/RedisConfig.java @@ -1,7 +1,10 @@ package com.couponify.couponapi.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.redisson.Redisson; import org.redisson.api.RedissonClient; +import org.redisson.codec.JsonJacksonCodec; import org.redisson.config.Config; import org.redisson.spring.data.connection.RedissonConnectionFactory; import org.springframework.beans.factory.annotation.Value; @@ -26,6 +29,12 @@ public RedissonClient redissonClient() { config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setPassword(password); + + config.setCodec(new JsonJacksonCodec( + new ObjectMapper() + .registerModule(new JavaTimeModule()) + )); + return Redisson.create(config); } diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java index 73d3e19..7555430 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/exception/CouponErrorCode.java @@ -6,6 +6,8 @@ @Getter public enum CouponErrorCode { COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 쿠폰입니다. : [%s]"), + COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "이미 쿠폰을 발급받은 사용자입니다."), + TRANSACTION_COMMIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "트랜잭션 커밋에 실패했습니다. : [%s]"), LOCK_ACQUISITION_FAILED(HttpStatus.LOCKED, "락을 획득할 수 없습니다."); private final HttpStatus status; diff --git a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java index 9deb9db..514c1aa 100644 --- a/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java +++ b/service/coupon-api/src/main/java/com/couponify/couponapi/presentation/CouponController.java @@ -1,11 +1,13 @@ package com.couponify.couponapi.presentation; +import com.couponify.couponapi.application.CouponCreateService; +import com.couponify.couponapi.application.CouponIssueService; import com.couponify.couponapi.application.CouponLockService; -import com.couponify.couponapi.application.CouponService; import com.couponify.couponapi.presentation.request.CouponCreateRequest; import jakarta.validation.Valid; import java.net.URI; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -19,13 +21,14 @@ @RequiredArgsConstructor public class CouponController { - private final CouponService couponService; + private final CouponCreateService couponCreateService; + private final CouponIssueService couponIssueService; private final CouponLockService couponLockService; @PostMapping public ResponseEntity create( @Valid @RequestBody CouponCreateRequest couponCreateRequest) { - Long savedCouponId = couponService.create(couponCreateRequest); + Long savedCouponId = couponCreateService.create(couponCreateRequest); return ResponseEntity.created(URI.create("/coupon/" + savedCouponId)).build(); } @@ -33,8 +36,8 @@ public ResponseEntity create( public ResponseEntity issue( @PathVariable(name = "couponId") Long couponId, @RequestParam(name = "user-id") Long userId) { - Long savedIssuedCouponId = couponLockService.issueRLock(couponId, userId); - return ResponseEntity.created(URI.create("/issuedCoupon/" + savedIssuedCouponId)).build(); + couponLockService.cacheCouponIssuance(couponId, userId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } } diff --git a/service/coupon-api/src/main/resources/application.yml b/service/coupon-api/src/main/resources/application.yml index 3c9efbf..65544f2 100644 --- a/service/coupon-api/src/main/resources/application.yml +++ b/service/coupon-api/src/main/resources/application.yml @@ -9,5 +9,12 @@ spring: - classpath:properties/jpa.yml schedule: - cron: "0 0 0 * * *" use: true + cron: + expire: "0 0 0 * * *" + issue: "*/10 * * * * *" + +cache: + coupon: + expiration: + hours: 1 diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java index 2da8fbb..9ba7b14 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Coupon.java @@ -66,6 +66,13 @@ public void issue(int quantity) { decreaseQuantity(quantity); } + public void decreaseQuantity(int quantity) { + this.quantity.decrease(quantity); + if (this.quantity.checkIsZero()) { + updateStatus(CouponStatus.SOLD_OUT); + } + } + public void expire() { updateStatus(CouponStatus.EXPIRED); } @@ -127,11 +134,5 @@ private boolean isIssuePeriod() { && this.issueEndAt.isAfter(LocalDateTime.now())); } - private void decreaseQuantity(int quantity) { - this.quantity.decrease(quantity); - if (this.quantity.isZero()) { - updateStatus(CouponStatus.SOLD_OUT); - } - } } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java new file mode 100644 index 0000000..9f22339 --- /dev/null +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/CouponCache.java @@ -0,0 +1,94 @@ +package com.couponify.coupondomain.domain.coupon; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponCache { + + private Long id; + private CouponStatus status; + private Quantity quantity; + private LocalDateTime issueStartAt; + private LocalDateTime issueEndAt; + private Set issuerIds = new HashSet<>(); + + public static CouponCache of(Coupon coupon, Set issuerIds) { + return new CouponCache( + coupon.getId(), + coupon.getStatus(), + new Quantity(coupon.getQuantity()), + coupon.getIssueStartAt(), + coupon.getIssueEndAt(), + issuerIds + ); + } + + public void issue(Long userId, int quantity) { + validateDuplicateIssuer(userId); + validateIssuable(quantity); + decreaseQuantity(quantity); + } + + public void addIssuers(Set issuerIds) { + issuerIds.forEach(this::addIssuer); + } + + private void addIssuer(Long userId) { + issuerIds.add(userId); + } + + private void validateDuplicateIssuer(Long userId) { + if (issuerIds.contains(userId)) { + throw new IllegalArgumentException("이미 쿠폰을 발급한 사용자입니다."); + } + } + + private void validateIssuable(int quantity) { + validateIssueStatus(); + validateIssueQuantity(quantity); + validateIssuePeriod(); + } + + private void validateIssueStatus() { + if (!this.status.isIssuable()) { + throw new IllegalArgumentException("쿠폰 발급 가능 상태가 아닙니다."); + } + } + + private void validateIssueQuantity(int quantity) { + if (!this.quantity.isGreaterThanOrEqualTo(quantity)) { + throw new IllegalArgumentException("쿠폰 수량이 부족합니다."); + } + } + + private void validateIssuePeriod() { + if (!isIssuePeriod()) { + throw new IllegalArgumentException("쿠폰 발급 기간이 아닙니다."); + } + } + + private boolean isIssuePeriod() { + return (this.issueStartAt.isBefore(LocalDateTime.now()) + && this.issueEndAt.isAfter(LocalDateTime.now())); + } + + private void decreaseQuantity(int quantity) { + this.quantity.decrease(quantity); + if (this.quantity.checkIsZero()) { + soldOut(); + } + } + + private void soldOut() { + this.status = CouponStatus.SOLD_OUT; + } + +} diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java index d654b5b..4ebbb51 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/coupon/Quantity.java @@ -27,7 +27,7 @@ public void increase(int amount) { this.value += amount; } - public boolean isZero() { + public boolean checkIsZero() { return this.value == 0; } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java index b7a0405..6deddb5 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/domain/issuedCoupon/repository/IssuedCouponRepository.java @@ -1,10 +1,16 @@ package com.couponify.coupondomain.domain.issuedCoupon.repository; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import java.util.List; public interface IssuedCouponRepository { IssuedCoupon save(IssuedCoupon issuedCoupon); + List saveAll(List issuedCoupons); + + List findUserIdsByCoupon(Coupon coupon); + } diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java index 7df4792..5d565f5 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/IssuedCouponRepositoryImpl.java @@ -1,7 +1,9 @@ package com.couponify.coupondomain.infrastructure.jpa.issuedCoupon; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; import com.couponify.coupondomain.domain.issuedCoupon.repository.IssuedCouponRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -11,6 +13,16 @@ public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { private final JpaIssuedCouponRepository jpaIssuedCouponRepository; + @Override + public List saveAll(List issuedCoupons) { + return jpaIssuedCouponRepository.saveAll(issuedCoupons); + } + + @Override + public List findUserIdsByCoupon(Coupon coupon) { + return jpaIssuedCouponRepository.findUserIdsByCoupon(coupon); + } + @Override public IssuedCoupon save(IssuedCoupon issuedCoupon) { return jpaIssuedCouponRepository.save(issuedCoupon); diff --git a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java index 178c775..618dc9f 100644 --- a/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java +++ b/service/coupon-domain/src/main/java/com/couponify/coupondomain/infrastructure/jpa/issuedCoupon/JpaIssuedCouponRepository.java @@ -1,8 +1,15 @@ package com.couponify.coupondomain.infrastructure.jpa.issuedCoupon; +import com.couponify.coupondomain.domain.coupon.Coupon; import com.couponify.coupondomain.domain.issuedCoupon.IssuedCoupon; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface JpaIssuedCouponRepository extends JpaRepository { + @Query("SELECT i.userId from IssuedCoupon i WHERE i.coupon = :coupon") + List findUserIdsByCoupon(@Param("coupon") Coupon coupon); + }