Skip to content

Commit

Permalink
사전예약주문 API 구현 (#127)
Browse files Browse the repository at this point in the history
* feat(#100): 카프카에서 사용하는 토픽들을 관리하는 문자열 상수 클래스를 commons에 정의

* feat(#22): Dto 모듈에 OrderDto 생성

* chore(#100): 카프카 설정 및 PreOrderProducer 등록
- 카프카 의존성 및 설정
- 오더서버로 사전예약상품 정보를 전송하는 카프카 빈 추가

* rename(#100): ProductSearchDto를 utils 패키지로 이동

* feat(#100): 사전예약주문 API 구현
- Redission 으로 분산락 구현
- validate로 사전예약주문기간, 중복여부, 사전예약수량 여부 확인

---------

Co-authored-by: linavell <linavell@naver.com>
  • Loading branch information
yooyouny and eggnee authored Oct 15, 2024
1 parent ca39f8b commit b76b683
Show file tree
Hide file tree
Showing 25 changed files with 368 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.sparta.common.domain.entity;

public class KafkaTopicConstant {
public static final String PROCESS_PREORDER = "process_preorder";
public static final String ERROR_IN_PROCESS_PREORDER = "error_in_create_delivery";
public static final String PAYMENT_COMPLETED = "payment-completed-topic";
}
1 change: 0 additions & 1 deletion service/order/dto/src/main/java/dto/OrderDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public static class OrderCreateRequest {
private List<OrderProductInfo> orderProductInfos = new ArrayList<>();
private BigDecimal pointPrice;
private Long addressId;

}

@Getter
Expand Down
3 changes: 3 additions & 0 deletions service/product/server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ dependencies {
implementation project(':common:domain')
implementation project(':service:product:product_dto')
implementation project(':service:auth:auth_dto')
implementation project(':service:order:dto')

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-cassandra'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson:3.35.0'
implementation 'org.springframework.kafka:spring-kafka'
implementation 'org.springframework.data:spring-data-elasticsearch:5.3.0'
implementation 'co.elastic.clients:elasticsearch-java:8.6.0'
implementation 'org.springframework.boot:spring-boot-starter-json:3.3.4'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.sparta.product.application.preorder;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j(topic = "DistributedLockComponent")
public class DistributedLockComponent {
private final RedissonClient redissonClient;

public void execute(
String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
}
logic.run();
} catch (InterruptedException e) {
log.error(e.getMessage(), e);
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.sparta.product.application.preorder;

import com.sparta.product.domain.model.PreOrder;
import com.sparta.product.infrastructure.utils.PreOrderRedisDto;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PreOrderCacheService {
private final PreOrderService preOrderService;

@Cacheable(cacheNames = "preOrder", key = "#preOrderId")
public PreOrderRedisDto getPreOrderCache(Long preOrderId) {
PreOrder preOrder = preOrderService.findPreOrderByPreOrderId(preOrderId);
return new PreOrderRedisDto(preOrder);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.sparta.product.application.preorder;

import com.sparta.common.domain.entity.KafkaTopicConstant;
import com.sparta.product.domain.model.PreOrder;
import com.sparta.product.domain.model.PreOrderState;
import com.sparta.product.infrastructure.messaging.PreOrderProducer;
import com.sparta.product.presentation.exception.ProductErrorCode;
import com.sparta.product.presentation.exception.ProductServerException;
import dto.OrderDto.OrderCreateRequest;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class PreOrderFacadeService {
private final PreOrderService preOrderService;
private final PreOrderProducer preOrderProducer;
private final PreOrderLockService preOrderLockService;

@Transactional
public void preOrder(Long preOrderId, Long addressId, Long userId) {
PreOrder preOrder = preOrderService.findPreOrderByPreOrderId(preOrderId);
preOrderLockService.reservation(preOrderId, userId);
OrderCreateRequest createRequest = PreOrderMapper.toDto(preOrderId, addressId);
preOrderProducer.send(KafkaTopicConstant.PROCESS_PREORDER, preOrderId, createRequest);
}

private void validatePreOrder(PreOrder preOrder) {
if (preOrder.getState() != PreOrderState.OPEN_FOR_ORDER)
throw new ProductServerException(ProductErrorCode.NOT_OPEN_FOR_PREORDER);

LocalDateTime now = LocalDateTime.now();
if (now.isBefore(preOrder.getStartDateTime()) || now.isAfter(preOrder.getEndDateTime())) {
throw new ProductServerException(ProductErrorCode.CLOSED_PREORDER);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sparta.product.application.preorder;

import static com.sparta.product.infrastructure.utils.RedisUtils.getRedisKeyOfPreOrder;

import com.sparta.product.infrastructure.utils.PreOrderRedisDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PreOrderLockService {
private final PreOrderRedisService redisService;
private final PreOrderCacheService cacheService;
private final DistributedLockComponent lockComponent;

public void reservation(long preOrderId, long userId) {
PreOrderRedisDto cachedPreOrder = cacheService.getPreOrderCache(preOrderId);
cachedPreOrder.validateReservationDate(); // 사전예약기간인지 검증
lockComponent.execute( // 락을 걸고
"preOrderLock_%s".formatted(preOrderId),
3000,
3000,
() -> {
redisService.validateQuantity(cachedPreOrder, userId); // 이미 예약에 성공한 유저인지, 수량안에 든 유저인지 검증
});
redisService.preOrder(getRedisKeyOfPreOrder(preOrderId), userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.sparta.product.domain.model.PreOrder;
import com.sparta.product.presentation.request.PreOrderCreateRequest;
import dto.OrderDto.OrderCreateRequest;
import dto.OrderDto.OrderProductInfo;
import java.util.List;

public class PreOrderMapper {
public static PreOrder toEntity(PreOrderCreateRequest request) {
Expand All @@ -14,4 +17,9 @@ public static PreOrder toEntity(PreOrderCreateRequest request) {
.availableQuantity(request.availableQuantity())
.build();
}

public static OrderCreateRequest toDto(Long preOrderId, Long addressId) {
OrderProductInfo orderProduct = new OrderProductInfo(preOrderId.toString(), 1, null);
return new OrderCreateRequest("PREORDER", List.of(orderProduct), null, addressId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.sparta.product.application.preorder;

import static com.sparta.product.infrastructure.utils.RedisUtils.getRedisKeyOfPreOrder;

import com.sparta.product.domain.repository.redis.RedisRepository;
import com.sparta.product.infrastructure.utils.PreOrderRedisDto;
import com.sparta.product.presentation.exception.ProductErrorCode;
import com.sparta.product.presentation.exception.ProductServerException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PreOrderRedisService {
private final RedisRepository redisRepository;

public void validateQuantity(PreOrderRedisDto cache, long userId) {
if (!availableUser(cache.preOrderId(), userId))
throw new ProductServerException(ProductErrorCode.ALREADY_PREORDER);
if (!availableQuantity(cache.availableQuantity(), cache.preOrderId()))
throw new ProductServerException(ProductErrorCode.EXCEED_PREORDER_QUANTITY);
}

public void preOrder(String key, long userId) {
redisRepository.sAdd(key, Long.toString(userId));
}

public boolean availableUser(long preOrderId, long userId) { // 중복 요청 확인
String key = getRedisKeyOfPreOrder(preOrderId);
return !redisRepository.sIsMember(key, String.valueOf(userId));
}

public boolean availableQuantity(int availableQuantity, long preOrderId) { // 수량 검증
String key = getRedisKeyOfPreOrder(preOrderId);
return availableQuantity > redisRepository.sCard(key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public void deletePreOrder(Long preOrderId) {
preOrderRepository.delete(preOrder);
}

private PreOrder findPreOrderByPreOrderId(Long preOrderId) {
public PreOrder findPreOrderByPreOrderId(Long preOrderId) {
return preOrderRepository
.findByPreOrderIdAndIsPublicTrue(preOrderId)
.orElseThrow(() -> new ProductServerException(ProductErrorCode.NOT_FOUND_PREORDER));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.sparta.product.domain.model.SortOption;
import com.sparta.product.domain.repository.ElasticSearchRepository;
import com.sparta.product.domain.repository.ElasticsearchCustomRepository;
import com.sparta.product.infrastructure.elasticsearch.dto.ProductSearchDto;
import com.sparta.product.infrastructure.utils.ProductSearchDto;
import com.sparta.product.presentation.response.ProductResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.sparta.product.domain.repository;

import com.sparta.product.infrastructure.elasticsearch.dto.ProductSearchDto;
import com.sparta.product.infrastructure.utils.ProductSearchDto;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface ElasticSearchRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import co.elastic.clients.elasticsearch.core.search.TotalHits;
import co.elastic.clients.json.JsonData;
import com.sparta.product.domain.model.SortOption;
import com.sparta.product.infrastructure.elasticsearch.dto.ProductSearchDto;
import com.sparta.product.infrastructure.utils.ProductSearchDto;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.sparta.product.domain.repository.redis;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class RedisRepository {
private final RedisTemplate<String, String> redisTemplate;

public Long sAdd(String key, String value) {
return redisTemplate.opsForSet().add(key, value);
}

public Long sCard(String key) {
return redisTemplate.opsForSet().size(key);
}

public Boolean sIsMember(String key, String value) {
return redisTemplate.opsForSet().isMember(key, value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.sparta.product.infrastructure.configuration;

import com.sparta.product.infrastructure.messaging.PreOrderProducer;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;

@ConditionalOnProperty(value = "kafka.enabled", matchIfMissing = true)
@RequiredArgsConstructor
@Configuration
public class KafkaConfig {
private final KafkaTemplate<String, Object> kafkaTemplate;

@Bean
public PreOrderProducer preOrderProducer() {
return new PreOrderProducer(kafkaTemplate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,29 @@
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.CacheKeyPrefix;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);

RedisCacheConfiguration configuration =
RedisCacheConfiguration redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofDays(7))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(jsonRedisSerializer));
;

return RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(configuration).build();
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(30));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.sparta.product.infrastructure.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 RedissonConfig {
private static final String REDIS_URL_PREFIX = "redis://";

@Value("${spring.data.redis.host}")
private String host;

@Value("${spring.data.redis.port}")
private int port;

@Bean
RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDIS_URL_PREFIX + host + ":" + port);
return Redisson.create(config);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sparta.product.infrastructure.messaging;

import dto.OrderDto.OrderCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;

@RequiredArgsConstructor
@Slf4j(topic = "PreOrderProducer in Product server")
public class PreOrderProducer {
private final KafkaTemplate<String, Object> kafkaTemplate;

public void send(String topic, Long preOrderId, OrderCreateRequest request) {
kafkaTemplate.send(topic, preOrderId.toString(), request);
log.info("send preorderRequest of {} to order server", preOrderId);
}
}
Loading

0 comments on commit b76b683

Please sign in to comment.