diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java index ad81fbf7..43ab7ca4 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java @@ -43,6 +43,7 @@ import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException; import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; import com.nowait.domaincorerdb.reservation.exception.InvalidReservationParameterException; import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException; @@ -54,7 +55,6 @@ import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException; import com.nowait.domaincorerdb.reservation.exception.ReservationViewUnauthorizedException; import com.nowait.domaincorerdb.reservation.exception.UnsupportedReservationStatusException; -import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincorerdb.store.exception.StoreDeleteUnauthorizedException; import com.nowait.domaincorerdb.store.exception.StoreImageEmptyException; import com.nowait.domaincorerdb.store.exception.StoreImageNotFoundException; @@ -325,12 +325,13 @@ public ErrorResponse unsupportedReservationStatusException(UnsupportedReservatio return new ErrorResponse(e.getMessage(), UNSUPPORTED_RESERVATION_STATUS.getCode()); } - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(UserWaitingLimitExceededException.class) - public ErrorResponse userWaitingLimitExceededException(UserWaitingLimitExceededException e, WebRequest request) { + + @ResponseStatus(CONFLICT) + @ExceptionHandler(AlreadyDeletedWaitingException.class) + public ErrorResponse alreadyDeletedWaitingException(AlreadyDeletedWaitingException e, WebRequest request) { alarm(e, request); - log.error("userWaitingLimitExceededException", e); - return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode()); + log.error("alreadyDeletedWaitingException", e); + return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode()); } diff --git a/nowait-app-user-api/build.gradle b/nowait-app-user-api/build.gradle index a8cafc73..ca819c48 100644 --- a/nowait-app-user-api/build.gradle +++ b/nowait-app-user-api/build.gradle @@ -40,7 +40,6 @@ dependencies { // SPRING SECURITY implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // jwt @@ -85,4 +84,4 @@ dependencies { test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java index 78c82358..de20f1f3 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java @@ -28,16 +28,17 @@ import com.nowait.domaincorerdb.order.exception.DuplicateOrderException; import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; -import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException; import com.nowait.domaincorerdb.token.exception.BusinessException; import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domainuserrdb.bookmark.exception.AlreadyDeletedBookmarkException; import com.nowait.domainuserrdb.bookmark.exception.BookmarkNotFoundException; import com.nowait.domainuserrdb.bookmark.exception.BookmarkOwnerMismatchException; @@ -223,6 +224,14 @@ public ErrorResponse duplicateReservationException(DuplicateReservationException return new ErrorResponse(e.getMessage(), DUPLICATE_RESERVATION.getCode()); } + @ResponseStatus(CONFLICT) + @ExceptionHandler(AlreadyDeletedWaitingException.class) + public ErrorResponse alreadyWaitingException(AlreadyDeletedWaitingException e, WebRequest request) { + alarm(e, request); + log.error("alreadyWaitingException", e); + return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode()); + } + @ResponseStatus(BAD_REQUEST) @ExceptionHandler(StoreWaitingDisabledException.class) public ErrorResponse storeWaitingDisabledException(StoreWaitingDisabledException e, WebRequest request) { diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java index 2416e0d5..57ca9cb5 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java @@ -1,7 +1,5 @@ package com.nowait.applicationuser.reservation.service; -import static com.nowait.common.exception.ErrorMessage.*; - import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; @@ -36,7 +34,6 @@ import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; -import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; import com.nowait.domaincorerdb.store.entity.ImageType; import com.nowait.domaincorerdb.store.entity.Store; @@ -49,6 +46,7 @@ import com.nowait.domaincorerdb.user.exception.UserNotFoundException; import com.nowait.domaincorerdb.user.repository.UserRepository; import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java new file mode 100644 index 00000000..220e3025 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java @@ -0,0 +1,80 @@ +package com.nowait.applicationuser.waiting.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.service.WaitingService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Waiting API", description = "예약 API -리팩토링 중-") +@RestController +@RequestMapping("/v2/users/me/waitings") +@RequiredArgsConstructor +public class WaitingController { + private final WaitingService waitingService; + + /** + * 대기열 리팩토링용 API + */ + @PostMapping("/{publicCode}") + @Operation(summary = "대기열 리팩토링용 API", description = "대기열 리팩토링용 API") + public ResponseEntity registerWaiting( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable String publicCode, + @RequestBody RegisterWaitingRequest request, + HttpServletRequest httpServletRequest + ) { + RegisterWaitingResponse registerWaitingResponse = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + registerWaitingResponse + ) + ); + } + + @DeleteMapping("/{publicCode}") + @Operation(summary = "대기열 리팩토링용 API", description = "대기 취소") + public ResponseEntity cancelWaiting( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable String publicCode, + @RequestBody CancelWaitingRequest request + ) { + CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting( + customOAuth2User, + publicCode, + request + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + cancelWaitingResponse + ) + ); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java new file mode 100644 index 00000000..f39200f4 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java @@ -0,0 +1,12 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CancelWaitingRequest { + private String waitingNumber; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java new file mode 100644 index 00000000..dbd77037 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java @@ -0,0 +1,18 @@ +package com.nowait.applicationuser.waiting.dto; + +import java.time.LocalDateTime; + +import com.nowait.common.enums.ReservationStatus; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CancelWaitingResponse { + private String waitingNumber; + private Long storeId; + private ReservationStatus reservationStatus; + private LocalDateTime canceledAt; + private String message; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java new file mode 100644 index 00000000..df823445 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java @@ -0,0 +1,12 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RegisterWaitingRequest { + private Integer partySize; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java new file mode 100644 index 00000000..52e940ba --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java @@ -0,0 +1,11 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RegisterWaitingResponse { + private final String waitingNumber; + private final int partySize; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java new file mode 100644 index 00000000..7ccbc99f --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WaitingIdempotencyValue { + private String state; + private RegisterWaitingResponse response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java new file mode 100644 index 00000000..35716590 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java @@ -0,0 +1,18 @@ +package com.nowait.applicationuser.waiting.event; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AddWaitingRegisterEvent { + @NotNull + private final Long storeId; + @NotNull + private final Long userId; + @NotNull + private final LocalDateTime timestamp; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java new file mode 100644 index 00000000..f5aa4aff --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java @@ -0,0 +1,27 @@ +package com.nowait.applicationuser.waiting.event.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AddWaitingRegisterListener { + + private final WaitingRedisRepository waitingRedisRepository; + + @Async + @TransactionalEventListener( + classes = AddWaitingRegisterEvent.class, + phase = TransactionPhase.AFTER_COMMIT + ) + public void onAddWaitingRegister(AddWaitingRegisterEvent event) { + waitingRedisRepository.addWaiting(event.getStoreId(), event.getUserId(), event.getTimestamp()); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java new file mode 100644 index 00000000..791b3880 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java @@ -0,0 +1,53 @@ +package com.nowait.applicationuser.waiting.redis; + +import java.time.Duration; +import java.util.Optional; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WaitingIdempotencyRepository { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final Duration TTL = Duration.ofMinutes(10); + + public Optional findByKey(String key) { + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + return Optional.empty(); + } + + try { + return Optional.of( + objectMapper.readValue(value, WaitingIdempotencyValue.class) + ); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize value from Redis", e); + } + } + + public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { + WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue( + "COMPLETED", + response + ); + + try { + String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue); + redisTemplate.opsForValue().set(key, jsonValue, TTL); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize value for Redis", e); + } + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java new file mode 100644 index 00000000..e1165c13 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -0,0 +1,169 @@ +package com.nowait.applicationuser.waiting.service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WaitingService { + + private final ReservationRepository reservationRepository; + private final WaitingRedisRepository waitingRedisRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + private final WaitingIdempotencyRepository waitingIdempotencyRepository; + + /** + * 최초 대기 등록 + * @param publicCode + * @param waitingRequest + */ + // 대기열 리팩토링 서비스 메서드 + @Transactional + public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { + + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + // TODO 멱등성 검증 로직 점검 필요 + Optional existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); + if (existingIdempotencyValue.isPresent()) { + log.info("Existing idempotency key found: {}", idempotentKey); + return existingIdempotencyValue.get().getResponse(); + } + + // TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯 + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + // 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호 + Long storeId = store.getStoreId(); + LocalDateTime timestamp = LocalDateTime.now(); + String waitingNumber = generateWaitingNumber(storeId, timestamp); + + // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 + // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.WAITING.name()); + + + // 일일 가능 웨이팅 최대 개수 초과 검증 + // TODO race condition 발생 가능성 점검 필요 + waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); + + // DB에 상태 값 저장 + Reservation reservation = Reservation.builder() + .reservationNumber(waitingNumber) + .store(store) + .user(user) + .status(ReservationStatus.WAITING) + .partySize(waitingRequest.getPartySize()) + .requestedAt(timestamp) + .updatedAt(timestamp) + .build(); + + reservationRepository.save(reservation); + + // Redis 대기열 추가 이벤트 발행 + eventPublisher.publishEvent( + new AddWaitingRegisterEvent( + storeId, + user.getId(), + timestamp + ) + ); + + RegisterWaitingResponse response = RegisterWaitingResponse.builder() + .waitingNumber(waitingNumber) + .partySize(waitingRequest.getPartySize()) + .build(); + + // 멱등키가 있다면 멱등 응답 저장 + waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + + return response; + } + + @Transactional + public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request) { + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new); + Long storeId = store.getStoreId(); + + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + // TODO 멱등키 검증 로직 점검 필요 + // String idempotentKey = generateIdempotentKey(storeId, user.getId()); + // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.CANCELLED.name()); + + // DB 웨이팅 상태 취소 처리 + Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) + .orElseThrow(ReservationNotFoundException::new); + + reservation.markAsCancelled(LocalDateTime.now()); + + // Redis 대기열 취소 이벤트 발행 + waitingRedisRepository.removeWaiting(storeId, user.getId()); + + return CancelWaitingResponse.builder() + .waitingNumber(reservation.getReservationNumber()) + .storeId(storeId) + .reservationStatus(reservation.getStatus()) + .canceledAt(reservation.getUpdatedAt()) + .message("대기 취소가 완료되었습니다.") + .build(); + } + + private String generateWaitingNumber(Long storeId, LocalDateTime timestamp) { + // 1) 키 접두사 - 날짜 + String today = timestamp.format(DateTimeFormatter.BASIC_ISO_DATE); // YYYYMMDD + + // atomic increment + // TODO 웨이팅 실패 시 카운터 롤백 처리 필요 + String dailySeqKey = RedisKeyUtils.buildWaitingSeqKey(storeId) + ":" + today; // ex. waiting:sequence:{storeId}:{today} + Long seqNum = waitingRedisRepository.incrementDailySequence(dailySeqKey); + + // 3) 4자리 0패딩 + String seqStr = String.format("%04d", seqNum); + + // 4) 최종 ID 조합 + return today + "-" + storeId + "-" + seqStr; + } + + private String generateIdempotentKey(Long storeId, Long userId) { + return "idempotentKey" + ":" + storeId + ":" + userId; + } +} diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java index bbba92f8..bb24d0cd 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java @@ -1,188 +1,188 @@ -package com.nowait.applicationuser.reservation.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; -import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; -import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; -import com.nowait.common.enums.Role; -import com.nowait.domaincoreredis.common.util.RedisKeyUtils; -import com.nowait.domaincorerdb.department.repository.DepartmentRepository; -import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; -import com.nowait.domaincorerdb.store.repository.StoreImageRepository; -import com.nowait.domaincorerdb.store.repository.StoreRepository; -import com.nowait.domaincorerdb.user.repository.UserRepository; -import com.nowait.domaincorerdb.store.entity.Store; -import com.nowait.domaincorerdb.user.entity.User; -import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; -import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; -import com.redis.testcontainers.RedisContainer; - -@Testcontainers -class ReservationServiceConcurrencyTest { - - @Container - static RedisContainer redis = new RedisContainer("redis:6.2.6"); - - static StringRedisTemplate redisTemplate; - static WaitingUserRedisRepository waitingRepo; - static ReservationService reservationService; - - // Mockito로 대체할 의존성들 - static ReservationRepository reservationRepo; - static StoreRepository storeRepo; - static UserRepository userRepo; - static DepartmentRepository deptRepo; - static StoreImageRepository storeImageRepo; - static WaitingPermitLuaRepository waitingPermitRepo; - - private static final Long STORE_ID = 100L; - private static final int THREAD_COUNT = 50; - - @BeforeAll - static void setupAll() { - // 1) RedisTemplate 초기화 - LettuceConnectionFactory factory = new LettuceConnectionFactory( - redis.getHost(), - redis.getFirstMappedPort() - ); - factory.afterPropertiesSet(); - redisTemplate = new StringRedisTemplate(factory); - - // 2) 실제 Redis 리포지토리 객체 생성 - waitingRepo = new WaitingUserRedisRepository(redisTemplate); - waitingPermitRepo = new WaitingPermitLuaRepository(redisTemplate); - - // 3) Mockito mock 인스턴스 생성 - reservationRepo = mock(ReservationRepository.class); - storeRepo = mock(StoreRepository.class); - userRepo = mock(UserRepository.class); - deptRepo = mock(DepartmentRepository.class); - storeImageRepo = mock(StoreImageRepository.class); - - // 4) store/user 유효성 검증 스텁 - Store mockStore = mock(Store.class); - when(mockStore.getIsActive()).thenReturn(true); - when(storeRepo.findById(anyLong())).thenReturn(Optional.of(mockStore)); - - when(userRepo.findById(anyLong())).thenAnswer(inv -> { - Long uid = inv.getArgument(0, Long.class); - User u = mock(User.class); - when(u.getRole()).thenReturn(Role.USER); - when(u.getId()).thenReturn(uid); - return Optional.of(u); - }); - - // 5) ReservationService 실체 생성 - reservationService = new ReservationService( - reservationRepo, - storeRepo, - userRepo, - waitingRepo, - deptRepo, - storeImageRepo, - waitingPermitRepo, - redisTemplate - ); - } - - @BeforeEach - void clearRedis() { - // 매 테스트마다 Redis 키 초기화 - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + "*")); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationNumberKey(STORE_ID) + ":*")); - redisTemplate.delete(redisTemplate.keys("waiting:user:*")); - redisTemplate.delete(RedisKeyUtils.buildReservationNumberKey(STORE_ID)); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); - } - - @Test - @DisplayName("동시 50명 대기 등록 테스트 (Given–When–Then)") - void concurrentRegisterWaiting() throws InterruptedException { - // --- Given --- - // 50개 스레드를 준비하고, 동시에 시작/종료를 제어할 CountDownLatch - ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch finishLatch = new CountDownLatch(THREAD_COUNT); - // 결과를 수집할 스레드 안전 리스트 - List responses = Collections.synchronizedList(new ArrayList<>()); - List errors = Collections.synchronizedList(new ArrayList<>()); - - // THREAD_COUNT개의 작업을 스레드풀에 제출 - for (int i = 0; i < THREAD_COUNT; i++) { - final long uid = 1000 + i; - exec.submit(() -> { - try { - // 모든 스레드가 startLatch.await() 대기 중 - startLatch.await(); - - // 각 스레드마다 OAuth2User mocking - CustomOAuth2User user = mock(CustomOAuth2User.class); - when(user.getUserId()).thenReturn(uid); - - // 실제 호출 - ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder() - .partySize(2) - .build(); - WaitingResponseDto responseDto = reservationService.registerWaiting(STORE_ID, user, dto); - responses.add(responseDto); - } catch (Throwable t) { - errors.add(t); - } finally { - // 작업 완료 신고 - finishLatch.countDown(); - } - }); - } - - // --- When --- - // 모든 스레드를 동시에 시작 - startLatch.countDown(); - // 최대 15초 대기 - boolean completedInTime = finishLatch.await(15, TimeUnit.SECONDS); - - // --- Then --- - // 1) 모든 스레드가 제시간에 완료되었는가? - assertTrue(completedInTime, "스레드가 제시간에 완료되지 않았습니다."); - assertTrue(errors.isEmpty(), "스레드 예외 발생: " + errors); - // 2) 반환된 DTO 개수가 50개인가? - assertEquals(THREAD_COUNT, responses.size(), "전체 응답 수 불일치"); - - // 3) 예약번호가 모두 유니크한가? - Set reservationIds = new HashSet<>(); - for (WaitingResponseDto r : responses) { - assertNotNull(r.getReservationNumber(), "예약번호가 null 입니다"); - reservationIds.add(r.getReservationNumber()); - } - assertEquals(THREAD_COUNT, reservationIds.size(), "예약번호가 중복되었습니다"); - - // 스레드풀 정리 - exec.shutdown(); - } - -} +// package com.nowait.applicationuser.reservation.service; +// +// import static org.junit.jupiter.api.Assertions.*; +// import static org.mockito.ArgumentMatchers.anyLong; +// import static org.mockito.Mockito.*; +// +// import java.util.ArrayList; +// import java.util.Collections; +// import java.util.HashSet; +// import java.util.List; +// import java.util.Optional; +// import java.util.Set; +// import java.util.concurrent.CountDownLatch; +// import java.util.concurrent.ExecutorService; +// import java.util.concurrent.Executors; +// import java.util.concurrent.TimeUnit; +// +// import org.junit.jupiter.api.BeforeAll; +// import org.junit.jupiter.api.BeforeEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +// import org.springframework.data.redis.core.StringRedisTemplate; +// import org.testcontainers.junit.jupiter.Container; +// import org.testcontainers.junit.jupiter.Testcontainers; +// +// import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +// import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +// import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +// import com.nowait.common.enums.Role; +// import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +// import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +// import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +// import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +// import com.nowait.domaincorerdb.store.repository.StoreRepository; +// import com.nowait.domaincorerdb.user.repository.UserRepository; +// import com.nowait.domaincorerdb.store.entity.Store; +// import com.nowait.domaincorerdb.user.entity.User; +// import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; +// import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; +// import com.redis.testcontainers.RedisContainer; +// +// @Testcontainers +// class ReservationServiceConcurrencyTest { +// +// @Container +// static RedisContainer redis = new RedisContainer("redis:6.2.6"); +// +// static StringRedisTemplate redisTemplate; +// static WaitingUserRedisRepository waitingRepo; +// static ReservationService reservationService; +// +// // Mockito로 대체할 의존성들 +// static ReservationRepository reservationRepo; +// static StoreRepository storeRepo; +// static UserRepository userRepo; +// static DepartmentRepository deptRepo; +// static StoreImageRepository storeImageRepo; +// static WaitingPermitLuaRepository waitingPermitRepo; +// +// private static final Long STORE_ID = 100L; +// private static final int THREAD_COUNT = 50; +// +// @BeforeAll +// static void setupAll() { +// // 1) RedisTemplate 초기화 +// LettuceConnectionFactory factory = new LettuceConnectionFactory( +// redis.getHost(), +// redis.getFirstMappedPort() +// ); +// factory.afterPropertiesSet(); +// redisTemplate = new StringRedisTemplate(factory); +// +// // 2) 실제 Redis 리포지토리 객체 생성 +// waitingRepo = new WaitingUserRedisRepository(redisTemplate); +// waitingPermitRepo = new WaitingPermitLuaRepository(redisTemplate); +// +// // 3) Mockito mock 인스턴스 생성 +// reservationRepo = mock(ReservationRepository.class); +// storeRepo = mock(StoreRepository.class); +// userRepo = mock(UserRepository.class); +// deptRepo = mock(DepartmentRepository.class); +// storeImageRepo = mock(StoreImageRepository.class); +// +// // 4) store/user 유효성 검증 스텁 +// Store mockStore = mock(Store.class); +// when(mockStore.getIsActive()).thenReturn(true); +// when(storeRepo.findById(anyLong())).thenReturn(Optional.of(mockStore)); +// +// when(userRepo.findById(anyLong())).thenAnswer(inv -> { +// Long uid = inv.getArgument(0, Long.class); +// User u = mock(User.class); +// when(u.getRole()).thenReturn(Role.USER); +// when(u.getId()).thenReturn(uid); +// return Optional.of(u); +// }); +// +// // 5) ReservationService 실체 생성 +// reservationService = new ReservationService( +// reservationRepo, +// storeRepo, +// userRepo, +// waitingRepo, +// deptRepo, +// storeImageRepo, +// waitingPermitRepo, +// redisTemplate +// ); +// } +// +// @BeforeEach +// void clearRedis() { +// // 매 테스트마다 Redis 키 초기화 +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationNumberKey(STORE_ID) + ":*")); +// redisTemplate.delete(redisTemplate.keys("waiting:user:*")); +// redisTemplate.delete(RedisKeyUtils.buildReservationNumberKey(STORE_ID)); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); +// } +// +// @Test +// @DisplayName("동시 50명 대기 등록 테스트 (Given–When–Then)") +// void concurrentRegisterWaiting() throws InterruptedException { +// // --- Given --- +// // 50개 스레드를 준비하고, 동시에 시작/종료를 제어할 CountDownLatch +// ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); +// CountDownLatch startLatch = new CountDownLatch(1); +// CountDownLatch finishLatch = new CountDownLatch(THREAD_COUNT); +// // 결과를 수집할 스레드 안전 리스트 +// List responses = Collections.synchronizedList(new ArrayList<>()); +// List errors = Collections.synchronizedList(new ArrayList<>()); +// +// // THREAD_COUNT개의 작업을 스레드풀에 제출 +// for (int i = 0; i < THREAD_COUNT; i++) { +// final long uid = 1000 + i; +// exec.submit(() -> { +// try { +// // 모든 스레드가 startLatch.await() 대기 중 +// startLatch.await(); +// +// // 각 스레드마다 OAuth2User mocking +// CustomOAuth2User user = mock(CustomOAuth2User.class); +// when(user.getUserId()).thenReturn(uid); +// +// // 실제 호출 +// ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder() +// .partySize(2) +// .build(); +// WaitingResponseDto responseDto = reservationService.registerWaiting(STORE_ID, user, dto); +// responses.add(responseDto); +// } catch (Throwable t) { +// errors.add(t); +// } finally { +// // 작업 완료 신고 +// finishLatch.countDown(); +// } +// }); +// } +// +// // --- When --- +// // 모든 스레드를 동시에 시작 +// startLatch.countDown(); +// // 최대 15초 대기 +// boolean completedInTime = finishLatch.await(15, TimeUnit.SECONDS); +// +// // --- Then --- +// // 1) 모든 스레드가 제시간에 완료되었는가? +// assertTrue(completedInTime, "스레드가 제시간에 완료되지 않았습니다."); +// assertTrue(errors.isEmpty(), "스레드 예외 발생: " + errors); +// // 2) 반환된 DTO 개수가 50개인가? +// assertEquals(THREAD_COUNT, responses.size(), "전체 응답 수 불일치"); +// +// // 3) 예약번호가 모두 유니크한가? +// Set reservationIds = new HashSet<>(); +// for (WaitingResponseDto r : responses) { +// assertNotNull(r.getReservationNumber(), "예약번호가 null 입니다"); +// reservationIds.add(r.getReservationNumber()); +// } +// assertEquals(THREAD_COUNT, reservationIds.size(), "예약번호가 중복되었습니다"); +// +// // 스레드풀 정리 +// exec.shutdown(); +// } +// +// } diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java index 39697323..74ef5038 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java @@ -38,6 +38,7 @@ import com.nowait.domaincorerdb.user.entity.User; import com.nowait.domaincorerdb.user.exception.UserNotFoundException; import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; @@ -356,7 +357,7 @@ void registerWaiting_LimitExceeded_GWT() { // When & Then assertThatThrownBy(() -> service.registerWaiting(storeId, principal, dto)) - .isInstanceOf(com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException.class) + .isInstanceOf(UserWaitingLimitExceededException.class) .hasMessageContaining("유저당 웨이팅 가능 개수"); verify(waitingRepo, never()).addToWaitingQueue(anyLong(), anyString(), anyInt(), anyLong()); diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java new file mode 100644 index 00000000..61a0cd4a --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -0,0 +1,156 @@ +package com.nowait.applicationuser.waiting.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import jakarta.servlet.http.HttpServletRequest; + +@ExtendWith(MockitoExtension.class) +class WaitingServiceTest { + + @InjectMocks + private WaitingService waitingService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private StoreRepository storeRepository; + @Mock + private UserRepository userRepository; + @Mock + private WaitingRedisRepository waitingRedisRepository; + @Mock + private ReservationRepository reservationRepository; + @Mock + WaitingIdempotencyRepository waitingIdempotencyRepository; + + @Test + @DisplayName("웨이팅 정상 등록 테스트") + void registerWaiting() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + String publicCode = "ZiVXAD1vVr5b"; + Long userId = 1L; + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(userId); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + RegisterWaitingResponse response = waitingService.registerWaiting(customOAuth2User, publicCode, request, httpServletRequest); + + // then + verify(waitingIdempotencyRepository).findByKey(anyString()); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotNull(); + } + + // @Test + // @DisplayName("DB 저장 실패 시 Redis 대기열 추가 이벤트 실행 되지 않음") + // void registerWaiting_dbSaveFail() { + // // given + // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + // RegisterWaitingRequest request = new RegisterWaitingRequest(4); + // + // Long storeId = 1L; + // Long userId = 1L; + // + // Store store = Store.builder().storeId(storeId).build(); + // User user = User.builder().id(userId).build(); + // + // // when + // when(customOAuth2User.getUserId()).thenReturn(userId); + // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + // when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + // + // when(reservationRepository.save(any(Reservation.class))).thenThrow(new RuntimeException("DB 저장 실패")); + // + // // then + // assertThatThrownBy(() -> + // waitingService.registerWaiting(customOAuth2User, storeId, request) + // ).isInstanceOf(RuntimeException.class); + // + // verify(waitingRedisRepository).idempotentKeyKeyExists(anyString(), eq("WAITING")); + // verify(eventPublisher, never()).publishEvent(any()); + // } + // + // @Test + // @DisplayName("없는 주점에 웨이팅 등록 시도 시 예외 발생") + // void registerWaiting_storeNotFound() { + // // given + // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + // RegisterWaitingRequest request = new RegisterWaitingRequest(4); + // + // Long storeId = -1L; // 존재하지 않는 주점 ID + // Long userId = 1L; + // + // // when + // when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); + // + // // then + // assertThatThrownBy(() -> + // waitingService.registerWaiting(customOAuth2User, storeId, request) + // ).isInstanceOf(StoreNotFoundException.class); + // + // verify(userRepository, never()).findById(anyLong()); + // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); + // verify(reservationRepository, never()).save(any(Reservation.class)); + // verify(eventPublisher, never()).publishEvent(any()); + // } + // + // @Test + // @DisplayName("없는 유저가 웨이팅 등록 시도 시 예외 발생") + // void registerWaiting_userNotFound() { + // // given + // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + // RegisterWaitingRequest request = new RegisterWaitingRequest(4); + // + // Long storeId = 1L; + // Long userId = -1L; // 존재 하지 않는 유저 ID + // + // Store store = Store.builder().storeId(storeId).build(); + // + // // when + // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + // when(customOAuth2User.getUserId()).thenReturn(userId); + // when(userRepository.findById(userId)).thenReturn(Optional.empty()); + // + // // then + // assertThatThrownBy(() -> + // waitingService.registerWaiting(customOAuth2User, storeId, request) + // ).isInstanceOf(UserNotFoundException.class); + // + // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); + // verify(reservationRepository, never()).save(any(Reservation.class)); + // verify(eventPublisher, never()).publishEvent(any()); + // } +} diff --git a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java index 754b4ba6..fa9e0bd7 100644 --- a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java +++ b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java @@ -38,6 +38,7 @@ public enum ErrorMessage { RESERVATION_ALREADY_CANCELLED("이미 취소된 예약입니다.", "reservation010"), UNSUPPORTED_RESERVATION_STATUS("지원하지 않는 예약 상태입니다: %s", "reservation011"), INVALID_RESERVATION_PARAMETER("잘못된 예약 요청 파라미터입니다. (%s)", "reservation012"), + ALREADY_DELETED_RESERVATION("이미 삭제된 예약입니다.", "reservation013"), // redis RESERVATION_DATA_INCONSISTENCY("예약 데이터가 Redis와 DB 간 일치하지 않습니다. (%s)", "redis001"), diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java index 4874444e..ab4efa20 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException; import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyCancelledException; import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyConfirmedException; @@ -78,6 +79,19 @@ public void markUpdated(LocalDateTime updatedAt, ReservationStatus newStatus) { this.updatedAt = updatedAt; } + public void markAsCancelled(LocalDateTime updatedAt) { + if (this.status == ReservationStatus.CANCELLED) { + throw new AlreadyDeletedWaitingException(); + } + + if (!isValidTransition(this.status, ReservationStatus.CANCELLED)) { + throw new InvalidReservationStatusTransitionException(this.status, ReservationStatus.CANCELLED); + } + + this.status = ReservationStatus.CANCELLED; + this.updatedAt = updatedAt; + } + private boolean isValidTransition(ReservationStatus current, ReservationStatus target) { return switch (current) { case WAITING -> target == ReservationStatus.CALLING diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java new file mode 100644 index 00000000..8e7a0357 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class AlreadyDeletedWaitingException extends RuntimeException { + public AlreadyDeletedWaitingException() { + super(ErrorMessage.ALREADY_DELETED_RESERVATION.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java index 5512a5f1..79ed84d5 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java @@ -25,4 +25,7 @@ Optional findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAt List findAllByStore_StoreIdAndStatusInAndRequestedAtBetween( Long storeId, List statuses, LocalDateTime start, LocalDateTime end); + + // 웨이팅 취소 처리 + Optional findReservationByReservationNumber(String reservationNumber); } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java index b27505c9..25467ea8 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -19,6 +19,7 @@ public class RedisKeyUtils { // Waiting keys private static final String WAITING_KEY_PREFIX = "waiting:"; + private static final String WAITING_USER_LIST_KEY_PREFIX = "waiting:user:"; private static final String WAITING_PARTYSIZE_KEY_PREFIX = "waiting:party:"; private static final String WAITING_STATUS_KEY_PREFIX = "waiting:status:"; @@ -55,6 +56,10 @@ public static DateTimeFormatter buildMenuDateKey() { return DTF; } + public static String buildWaitingUserListKeyPrefix() { + return WAITING_USER_LIST_KEY_PREFIX; + } + public static String buildWaitingKeyPrefix() { return WAITING_KEY_PREFIX; } @@ -72,6 +77,10 @@ public static String buildReservationSeqKey(Long storeId) { return String.format("reservation:seq:%d", storeId); } + public static String buildWaitingSeqKey(Long storeId) { + return String.format("waiting:sequence:%d", storeId); + } + public static String buildReservationNumberKey(Long storeId) { return String.format("reservation:number:%d", storeId); } @@ -89,6 +98,15 @@ public static String buildWaitingCalledAtKeyPrefix() { return "waiting:calledAt:"; } + /** + * 웨이팅 리팩토링 작업중 + */ + private static final String USER_WAITING_LIMIT_COUNT_KEY_FMT = "waiting:user:%s:limit:count"; + + public static String buildUserWaitingLimitCountKey(String userId) { + return String.format(USER_WAITING_LIMIT_COUNT_KEY_FMT, userId); + } + public static Date expireAtNext03() { ZoneId zone = ZoneId.of("Asia/Seoul"); LocalDateTime now = LocalDateTime.now(zone); @@ -97,4 +115,13 @@ public static Date expireAtNext03() { return Date.from(instant); } + + public static Date expireAt10Minute() { + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(zone); + LocalDateTime nextHour = now.toLocalDate().atTime(0, 10); + Instant instant = nextHour.atZone(zone).toInstant(); + + return Date.from(instant); + } } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java new file mode 100644 index 00000000..f028b155 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincoreredis.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class AlreadyWaitingException extends RuntimeException { + public AlreadyWaitingException() { + super(ErrorMessage.DUPLICATE_RESERVATION.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java similarity index 80% rename from nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java rename to nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java index dccdff12..8f594f9e 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java @@ -1,4 +1,4 @@ -package com.nowait.domaincorerdb.reservation.exception; +package com.nowait.domaincoreredis.reservation.exception; import com.nowait.common.exception.ErrorMessage; @@ -7,4 +7,3 @@ public UserWaitingLimitExceededException() { super(ErrorMessage.USER_WAITING_LIMIT_EXCEEDED.getMessage()); } } - diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java index 1af5b528..e11964f5 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java @@ -1,5 +1,8 @@ package com.nowait.domaincoreredis.reservation.repository; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -9,11 +12,15 @@ import org.springframework.stereotype.Repository; import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.exception.AlreadyWaitingException; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Repository @RequiredArgsConstructor +@Slf4j public class WaitingRedisRepository { private final StringRedisTemplate redisTemplate; @@ -74,7 +81,7 @@ public String getUserIdByReservationNumber(Long storeId, String reservationNumbe public void deleteWaiting(Long storeId, String userId) { String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); - String userMapKey = RedisKeyUtils.buildReservationUserKey(storeId); + String userMapKey = RedisKeyUtils.buildReservationUserKey(storeId); Object reservationNumber = redisTemplate.opsForHash().get(numberMapKey, userId); @@ -110,5 +117,110 @@ public Long getWaitingCalledAt(Long storeId, String userId) { Object val = redisTemplate.opsForHash().get(key, userId); return val == null ? null : Long.valueOf(val.toString()); } + + /** + * 웨이팅 대기열 리팩토링 작업중 + */ + // 대기열 추가 + public void addWaiting(Long storeId, Long userId, LocalDateTime timestamp) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + long score = timestamp + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + + // TODO ttl 설정 필요 + try { + redisTemplate.opsForZSet().add(queueKey, String.valueOf(userId), score); + log.info("웨이팅 대기열 추가 - storeId : {}, userId : {}", storeId, userId); + + redisTemplate.opsForZSet().add(userListKey, String.valueOf(storeId), score); + log.info("유저 웨이팅 목록 추가 - userId : {}, storeId : {}", userId, storeId); + } catch (Exception e) { + log.error("Redis 웨이팅 대기열 추가 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); + throw e; + } + } + + public void removeWaiting(Long storeId, Long userId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + try { + redisTemplate.opsForZSet().remove(queueKey, String.valueOf(userId)); + log.info("웨이팅 대기열 제거 - storeId : {}, userId : {}", storeId, userId); + + redisTemplate.opsForZSet().remove(userListKey, String.valueOf(storeId)); + log.info("유저 웨이팅 목록 제거 - userId : {}, storeId : {}", userId, storeId); + + redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); + log.info("유저 웨이팅 제한 카운트 감소 - userId : {}, currentCount : {}", userId, + redisTemplate.opsForValue().get(userWaitingLimitCountKey)); + } catch (Exception e) { + log.error("Redis 웨이팅 대기열 제거 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); + throw e; + } + } + + // 웨이팅 등록 요청 시 멱등키 검증 + public void idempotentKeyKeyExists(String idempotentKey, String status) { + Boolean success = redisTemplate.opsForValue() + .setIfAbsent( + idempotentKey, + status, + Duration.ofSeconds(10) + ); + + + // TODO 멱등하지 않은 요청 응답값 검토 필요 + if (Boolean.FALSE.equals(success)) { + throw new AlreadyWaitingException(); + } + } + + public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1); + log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey)); + + // TTL 없으면 하루 단위로 묶어야 함 (중요) + // redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); + + if (current != null && current > maxLimit) { + redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); + throw new UserWaitingLimitExceededException(); + } + } + + // 일일 시퀀스 증가 - 예약 번호 전용 + // TODO : 현재 중복 웨이팅 요청에도 시퀀스가 증가하는 문제가 있음 (추후 개선 필요) + public Long incrementDailySequence(String dailySeqKey) { + return redisTemplate.opsForValue().increment(dailySeqKey, 1); + } + + // TODO : 대기 순번 조회 (추후 사용 예정) + public Long getWaitingRank(Long storeId, Long userId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + return redisTemplate.opsForZSet().rank(queueKey, String.valueOf(userId)); + } + + // 웨이팅 여부 조회 + // TODO: 구현 필요 + public Boolean isWaiting(Long storeId, Long userId) { + redisTemplate.opsForZSet() + .score( + RedisKeyUtils.buildWaitingKeyPrefix() + storeId, + String.valueOf(userId) + ); + + Boolean isWaiting = true; + + return isWaiting; + } }