Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions service/coupon-api/docs/CouponApiTest.http
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
@@ -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<Coupon> 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<Long> couponIds) {
couponIds.forEach(couponId -> deleteCouponCache(couponId));
}

private void deleteCouponCache(Long couponId) {
RMap<Long, CouponCache> couponInfo = redissonClient.getMap(CouponPrefix.COUPON_INFO);
couponInfo.remove(couponId);
}

}
Original file line number Diff line number Diff line change
@@ -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<Long, Set<Long>> 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<Long> getIssuedCouponIds() {
RMap<Long, Set<Long>> couponIssuer = redissonClient.getMap(COUPON_ISSUER);
return couponIssuer.keySet();
}

private CouponCache getCouponCache(RTransaction transaction, Long couponId) {
RMapCache<Long, CouponCache> couponInfo = transaction.getMapCache(COUPON_INFO);
CouponCache couponCache = couponInfo.get(couponId);

if (couponCache == null) {
Coupon coupon = getCoupon(couponId);
Set<Long> issuerIds = getIssuerIds(coupon);
return CouponCache.of(coupon, issuerIds);
}
return couponCache;
}

private Set<Long> getIssuerIds(Coupon coupon) {
return new HashSet<>(issuedCouponRepository.findUserIdsByCoupon(coupon));
}

private void checkUserAlreadyIssued(RTransaction transaction, Long couponId, Long userId) {
RMap<Long, Set<Long>> couponIssuer = transaction.getMap(COUPON_ISSUER);
Set<Long> 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<Long, Set<Long>> couponIssuer = transaction.getMap(COUPON_ISSUER);
Set<Long> issuers = couponIssuer.get(couponId);
issuers.add(userId);
couponIssuer.put(couponId, issuers);
}

private void updateCouponCache(RTransaction transaction, Long couponId,
CouponCache couponCache) {
RMapCache<Long, CouponCache> couponInfo = transaction.getMapCache(COUPON_INFO);
couponInfo.put(couponId, couponCache);
}

private void setCouponCacheTTL(Long couponId, CouponCache couponCache) {
RMapCache<Long, CouponCache> couponInfo = redissonClient.getMapCache(
COUPON_INFO);
couponInfo.expire(Duration.ofHours(couponExpirationHours));
}

private void processCouponIssuance(
RTransaction transaction,
RMap<Long, Set<Long>> couponIssuer
) {
List<IssuedCoupon> issuedCoupons = new ArrayList<>();
Set<Long> issuedCouponIds = couponIssuer.keySet();

// 쿠폰 ID별 IssuedCoupon 생성 및 저장
for (Long issuedCouponId : issuedCouponIds) {
Set<Long> 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<IssuedCoupon> createIssuedCoupon(Set<Long> issuers, Coupon coupon) {
return issuers.stream()
.map(issuer -> IssuedCoupon.of(issuer, coupon))
.toList();
}

private void updateCouponInfoWithIssuer(RTransaction transaction, Long couponId,
Set<Long> 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)
);
}

}
Original file line number Diff line number Diff line change
@@ -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<Long> issuedCouponIds = couponIssueService.getIssuedCouponIds();
List<String> 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<String> generateIssuanceLockNames(List<Long> couponIds) {
return couponIds.stream().map(couponId -> LOCK_COUPON_PREFIX + couponId).toList();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down
Loading