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 3eb701b7..96a95d95 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 @@ -21,6 +21,7 @@ import com.nowait.applicationuser.security.exception.ResourceNotFoundException; import com.nowait.applicationuser.security.exception.UnauthorizedException; +import com.nowait.applicationuser.waiting.exception.WorkInProgressException; import com.nowait.common.exception.ErrorMessage; import com.nowait.common.exception.ErrorResponse; import com.nowait.discord.service.DiscordAlarmService; @@ -292,6 +293,15 @@ public ErrorResponse handleReservationAddUnauthorizedException( return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode()); } + @ResponseStatus(CONFLICT) + @ExceptionHandler(WorkInProgressException.class) + public ErrorResponse handleWorkInProgressException( + WorkInProgressException e, WebRequest request) { + alarm(e, request); + log.error("handleWorkInProgressException", e); + return new ErrorResponse(e.getMessage(), WORK_IN_PROGRESS.getCode()); + } + // 공통 에러 Map 생성 private static Map getErrors(MethodArgumentNotValidException e) { return e.getBindingResult() 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 index dbc2ed03..52c3bc63 100644 --- 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 @@ -1,5 +1,7 @@ package com.nowait.applicationuser.waiting.controller; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -12,6 +14,7 @@ import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.GetMyWaitingInfoResponse; import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; @@ -34,6 +37,24 @@ public class WaitingController { /** * 대기열 리팩토링용 API */ + @GetMapping() + @Operation(summary = "대기열 리팩토링용 API", description = "전체 대기 목록 조회") + public ResponseEntity getMyWaitingInfo( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + List response = waitingService.getMyWaitingInfo( + customOAuth2User + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + @PostMapping("/{publicCode}") @Operation(summary = "대기열 리팩토링용 API", description = "대기열 리팩토링용 API") public ResponseEntity registerWaiting( diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java new file mode 100644 index 00000000..dd33aef1 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java @@ -0,0 +1,23 @@ +package com.nowait.applicationuser.waiting.dto; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetMyWaitingInfoResponse { + private String reservationId; + private Long storeId; + private String storeName; + private String departmentName; + private Integer rank; + private Integer teamsAhead; + private Integer partySize; + private String status; + private LocalDateTime registeredAt; + private String location; + private String profileImageUrl; + private String bannerImageUrl; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.java new file mode 100644 index 00000000..00ccb5c9 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.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 IdempotencyResponse { + private String state; + private Object response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java new file mode 100644 index 00000000..8ea38bcd --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java @@ -0,0 +1,7 @@ +package com.nowait.applicationuser.waiting.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class WorkInProgressException extends RuntimeException { + public WorkInProgressException() { super(ErrorMessage.WORK_IN_PROGRESS.getMessage()); } +} 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 index 3fe4a3d8..505e1586 100644 --- 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 @@ -7,15 +7,16 @@ import org.springframework.stereotype.Repository; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; -import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.IdempotencyResponse; import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Repository @RequiredArgsConstructor +@Slf4j public class WaitingIdempotencyRepository { private final RedisTemplate redisTemplate; @@ -24,7 +25,7 @@ public class WaitingIdempotencyRepository { private static final Duration TTL = Duration.ofMinutes(10); // 멱등키 조회 메서드 - public Optional findByKey(String key) { + public Optional findByRegisterKey(String key) { String idempotencyValue = redisTemplate.opsForValue().get(key); if (idempotencyValue == null) { @@ -32,56 +33,53 @@ public Optional findByKey(String key) { } try { - return Optional.of( - objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class) - ); + log.info("Idempotency value found in Redis for key {}: {}", key, idempotencyValue); + return Optional.of(objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class)); } catch (Exception e) { throw new IllegalArgumentException("Failed to deserialize value from Redis", e); } } // 멱등키 조회 메서드 - public Optional findByCancelKey(String key) { + public WaitingCancelIdempotencyValue findByCancelKey(String key) { String idempotencyValue = redisTemplate.opsForValue().get(key); if (idempotencyValue == null) { - return Optional.empty(); + return null; } try { - return Optional.of( - objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.class) - ); + log.info("Idempotency value found in Redis for key {}: {}", key, idempotencyValue); + return objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.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( + // 멱등키 저장 메서드 + public void saveIdempotencyResponse(String key, Object response) { + IdempotencyResponse idempotencyResponse = new IdempotencyResponse( "COMPLETED", response ); try { - String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue); + String jsonValue = objectMapper.writeValueAsString(idempotencyResponse); redisTemplate.opsForValue().set(key, jsonValue, TTL); } catch (Exception e) { throw new IllegalArgumentException("Failed to serialize value for Redis", e); } } - // 멱등키 저장 메서드 - 대기 취소 - public void saveCancelIdempotencyValue(String key, CancelWaitingResponse response) { - WaitingCancelIdempotencyValue waitingIdempotencyValue = new WaitingCancelIdempotencyValue( - "COMPLETED", - response + public void saveIdempotencyInProgress(String key) { + IdempotencyResponse idempotencyResponse = new IdempotencyResponse( + "IN-PROGRESS", + null ); try { - String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue); + String jsonValue = objectMapper.writeValueAsString(idempotencyResponse); 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/IdempotencyService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/IdempotencyService.java new file mode 100644 index 00000000..7c039bf8 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/IdempotencyService.java @@ -0,0 +1,51 @@ +package com.nowait.applicationuser.waiting.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; +import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class IdempotencyService { + + private final WaitingIdempotencyRepository waitingIdempotencyRepository; + + // 멱등키 검증 메서드 + // TODO : 공통 멱등키 검증 메서드로 리팩토링 필요!!!!!!!!! + public Optional validateIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + return waitingIdempotencyRepository.findByRegisterKey(idempotentKey); + } + + public WaitingCancelIdempotencyValue validateIdempotencyCancel(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + return waitingIdempotencyRepository.findByCancelKey(idempotentKey); + } + + // 멱등키 응답 저장 메서드 + public void saveIdempotencyResponse(String idempotentKey, Object response) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + waitingIdempotencyRepository.saveIdempotencyResponse(idempotentKey, response); + } + } + + // 멱등키 PROCESSING 상태로 최초 저장 + public void saveIdempotencyKeyInProgress(String idempotentKey) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + log.info("Saving idempotency key in progress: {}", idempotentKey); + waitingIdempotencyRepository.saveIdempotencyInProgress(idempotentKey); + } + } + +} 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 index cfcd2476..b7f7a3b2 100644 --- 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 @@ -2,6 +2,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -9,17 +11,19 @@ import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.GetMyWaitingInfoResponse; import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; -import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; +import com.nowait.applicationuser.waiting.exception.WorkInProgressException; import com.nowait.common.enums.ReservationStatus; import com.nowait.domaincorerdb.department.entity.Department; import com.nowait.domaincorerdb.department.exception.DepartmentNotFoundException; import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; import com.nowait.domaincorerdb.reservation.entity.Reservation; import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; @@ -42,28 +46,31 @@ @Slf4j public class WaitingService { + private final IdempotencyService idempotencyService; private final ReservationRepository reservationRepository; private final WaitingRedisRepository waitingRedisRepository; private final StoreRepository storeRepository; private final UserRepository userRepository; private final DepartmentRepository departmentRepository; private final ApplicationEventPublisher eventPublisher; - private final WaitingIdempotencyRepository waitingIdempotencyRepository; /** * 최초 대기 등록 - * @param publicCode - * @param waitingRequest */ // 대기열 리팩토링 서비스 메서드 @Transactional public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) - RegisterWaitingResponse registerWaitingResponse = validateIdempotency(httpServletRequest); - if (registerWaitingResponse != null) { + Optional idempotencyResponse = idempotencyService.validateIdempotency(httpServletRequest); + if (idempotencyResponse.isPresent() && idempotencyResponse.getClass().equals("IN-PROGRESS")) { + throw new WorkInProgressException(); + } else if (idempotencyResponse.isPresent() && idempotencyResponse.getClass().equals("COMPLETED")) { log.info("Idempotent request detected. Returning existing response."); - return registerWaitingResponse; + return idempotencyResponse.get().getResponse(); + } else { + // TODO : DB 저장 실패 시 롤백 처리 필요 + idempotencyService.saveIdempotencyKeyInProgress(httpServletRequest.getHeader("Idempotency-Key")); } // TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯 @@ -110,7 +117,7 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri .build(); // TODO 멱등키 응답 실패 시 어떻게 처리할 지 점검 필요 - saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); + idempotencyService.saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); return response; } @@ -118,10 +125,14 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri @Transactional public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request, HttpServletRequest httpServletRequest) { // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) - CancelWaitingResponse cancelWaitingResponse = validateCancelIdempotency(httpServletRequest); - if (cancelWaitingResponse != null) { + WaitingCancelIdempotencyValue idempotencyResponse = idempotencyService.validateIdempotencyCancel(httpServletRequest); + if (idempotencyResponse != null && idempotencyResponse.getState().equals("IN-PROGRESS")) { + throw new WorkInProgressException(); + } else if (idempotencyResponse != null && idempotencyResponse.getState().equals("COMPLETED")) { log.info("Idempotent request detected. Returning existing response."); - return cancelWaitingResponse; + return idempotencyResponse.getResponse(); + } else { + idempotencyService.saveIdempotencyKeyInProgress(httpServletRequest.getHeader("Idempotency-Key")); } Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new); @@ -148,37 +159,41 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p .build(); // 멱등키가 있다면 멱등 응답 저장 - waitingIdempotencyRepository.saveCancelIdempotencyValue(httpServletRequest.getHeader("Idempotency-Key"), response); + idempotencyService.saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); return response; } - // 멱등키 검증 메서드 - private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServletRequest) { - String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); - - // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 - // TODO 멱등성 검증 로직 점검 필요 - return waitingIdempotencyRepository.findByKey(idempotentKey) - .map(WaitingIdempotencyValue::getResponse) - .orElse(null); - } - - private CancelWaitingResponse validateCancelIdempotency(HttpServletRequest httpServletRequest) { - String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); - - // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 - // TODO 멱등성 검증 로직 점검 필요 - return waitingIdempotencyRepository.findByCancelKey(idempotentKey) - .map(WaitingCancelIdempotencyValue::getResponse) - .orElse(null); - } + // 웨이팅 목록 조회 + public List getMyWaitingInfo(CustomOAuth2User oAuth2User) { + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); - // 멱등키 응답 저장 메서드 - private void saveIdempotencyResponse(String idempotentKey, RegisterWaitingResponse response) { - if (idempotentKey != null && !idempotentKey.isBlank()) { - waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); - } + Long userId = user.getId(); + + List waitingInfoList = reservationRepository.findMyWaitingInfo(userId); + + return waitingInfoList.stream() + .map(dto -> { + Long storeId = dto.getStoreId(); + Long rank = waitingRedisRepository.getWaitingCount(storeId); + + return GetMyWaitingInfoResponse.builder() + .reservationId(dto.getReservationId()) + .storeId(dto.getStoreId()) + .storeName(dto.getStoreName()) + .departmentName(dto.getDepartmentName()) + .rank(rank.intValue()) + .teamsAhead(rank.intValue() - 1) + .partySize(dto.getPartySize()) + .status(dto.getStatus().name()) + .registeredAt(dto.getRegisteredAt()) + .location(dto.getLocation()) + .profileImageUrl(dto.getProfileImageUrl()) + .bannerImageUrl(dto.getBannerImageUrl()) + .build(); + }) + .toList(); } // 현재 대기 인원 수 조회 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 index cb5b8f84..afbd23b5 100644 --- 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 @@ -5,6 +5,7 @@ import java.util.Optional; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,6 +21,7 @@ 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.exception.DuplicateReservationException; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; import com.nowait.domaincorerdb.store.entity.Store; import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; @@ -38,6 +40,8 @@ class WaitingServiceTest { @InjectMocks private WaitingService waitingService; + @InjectMocks + private IdempotencyService idempotencyService; @Mock private ApplicationEventPublisher eventPublisher; @Mock @@ -73,7 +77,7 @@ void registerWaiting_idempotentKeyExists() { .partySize(4) .build(); - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.of(new WaitingIdempotencyValue( "COMPLETED", idempotentResponse @@ -95,7 +99,36 @@ void registerWaiting_idempotentKeyExists() { verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); verify(reservationRepository, never()).save(any(Reservation.class)); verify(eventPublisher, never()).publishEvent(any()); - verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("멱등키가 In-PROGRESS 상태이면 DuplicateReservationException 발생") + void registerWaiting_idempotentKeyInProgress() { + // given + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(new WaitingIdempotencyValue( + "IN-PROGRESS", + null + ))); + + // when & then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(DuplicateReservationException.class); + + verify(storeRepository, never()).findByPublicCodeAndDeletedFalse(anyString()); + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), + any(RegisterWaitingResponse.class)); } @Test @@ -114,7 +147,7 @@ void registerWaiting_idempotentKeyNotExists() { User user = User.builder().id(userId).build(); when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); - when(customOAuth2User.getUserId()).thenReturn(10L); + when(customOAuth2User.getUserId()).thenReturn(userId); when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); doNothing() @@ -149,7 +182,7 @@ void registerWaiting_success() { RegisterWaitingRequest request = new RegisterWaitingRequest(4); when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); Long userId = 10L; @@ -184,14 +217,14 @@ void registerWaiting_success() { verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); verify(reservationRepository).save(any(Reservation.class)); verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); - verify(waitingIdempotencyRepository).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingIdempotencyRepository).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); } @Test @DisplayName("DB 저장 중 예외 발생 시 이벤트 발행 및 멱등 저장이 수행되지 않음") void registerWaiting_dbSaveException() { // given - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); RegisterWaitingRequest request = new RegisterWaitingRequest(4); @@ -222,7 +255,7 @@ void registerWaiting_dbSaveException() { )).isInstanceOf(RuntimeException.class); verify(eventPublisher, never()).publishEvent(any(AddWaitingRegisterEvent.class)); - verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); } @Test @@ -233,7 +266,7 @@ void registerWaiting_exceedWaitingLimit() { RegisterWaitingRequest request = new RegisterWaitingRequest(4); when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); @@ -262,7 +295,7 @@ void registerWaiting_exceedWaitingLimit() { verify(reservationRepository, never()).save(any(Reservation.class)); verify(eventPublisher, never()).publishEvent(any()); - verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); } @@ -270,7 +303,7 @@ void registerWaiting_exceedWaitingLimit() { @DisplayName("존재하지 않는 publicCode이면 StoreNotFoundException 발생") void registerWaiting_storeNotFound() { // given - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); RegisterWaitingRequest request = new RegisterWaitingRequest(4); @@ -296,7 +329,7 @@ void registerWaiting_storeNotFound() { @DisplayName("존재하지 않는 userId이면 UserNotFoundException 발생") void registerWaiting_userNotFound() { // given - when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); RegisterWaitingRequest request = new RegisterWaitingRequest(4); String publicCode = "ZiVXAD1vVr5b"; 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 ba58c78a..6aa6267e 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 @@ -93,6 +93,9 @@ public enum ErrorMessage { // search SEARCH_PARAMETER_EMPTY("검색어가 비어있습니다.", "search001"), + // idempotency + WORK_IN_PROGRESS("해당 요청이 이미 처리 중입니다. 잠시 후 다시 시도해주세요.", "idempotency001"), + // common UNEXPECTED_ERROR("예상하지 못한 오류가 발생했습니다.", "common999"); diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java new file mode 100644 index 00000000..dc641dbf --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java @@ -0,0 +1,49 @@ +package com.nowait.domaincorerdb.reservation.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.common.enums.ReservationStatus; +import com.querydsl.core.annotations.QueryProjection; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class GetMyWaitingBaseDto { + private final String reservationId; + private final Long storeId; + private final String storeName; + private final String departmentName; + private final Integer partySize; + private final ReservationStatus status; + private final LocalDateTime registeredAt; + private final String location; + private final String profileImageUrl; + private final String bannerImageUrl; + + @QueryProjection + public GetMyWaitingBaseDto( + String reservationId, + Long storeId, + String storeName, + String departmentName, + Integer partySize, + ReservationStatus status, + LocalDateTime registeredAt, + String location, + String profileImageUrl, + String bannerImageUrl + ) { + this.reservationId = reservationId; + this.storeId = storeId; + this.storeName = storeName; + this.departmentName = departmentName; + this.partySize = partySize; + this.status = status; + this.registeredAt = registeredAt; + this.location = location; + this.profileImageUrl = profileImageUrl == null ? "" : profileImageUrl; + this.bannerImageUrl = bannerImageUrl == null ? "" : bannerImageUrl; + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java new file mode 100644 index 00000000..eb4014a6 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java @@ -0,0 +1,12 @@ +package com.nowait.domaincorerdb.reservation.repository; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; + +@Repository +public interface ReservationCustomRepository { + List findMyWaitingInfo(Long userId); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java new file mode 100644 index 00000000..2138887a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java @@ -0,0 +1,87 @@ +package com.nowait.domaincorerdb.reservation.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.department.entity.QDepartment; +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; +import com.nowait.domaincorerdb.reservation.dto.QGetMyWaitingBaseDto; +import com.nowait.domaincorerdb.reservation.entity.QReservation; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.QStore; +import com.nowait.domaincorerdb.store.entity.QStoreImage; +import com.nowait.domaincorerdb.user.entity.QUser; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ReservationCustomRepositoryImpl implements ReservationCustomRepository { + + private final JPAQueryFactory queryFactory; + + private final QReservation reservation = QReservation.reservation; + private final QStore store = QStore.store; + private final QDepartment department = QDepartment.department; + private final QUser user = QUser.user; + private final QStoreImage storeImage = QStoreImage.storeImage; + + + @Override + public List findMyWaitingInfo(Long userId) { + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + + QStoreImage subStoreImage = new QStoreImage("subStoreImage"); + + return queryFactory + .select(new QGetMyWaitingBaseDto( + reservation.reservationNumber, + store.storeId, + store.name, + department.name, + reservation.partySize, + reservation.status, + reservation.requestedAt, + store.location, + storeImage.imageUrl, + + JPAExpressions + .select(subStoreImage.imageUrl) + .from(subStoreImage) + .where( + subStoreImage.store.storeId.eq(store.storeId), + subStoreImage.imageType.eq(ImageType.BANNER) + ) + .limit(1) + )) + .from(reservation) + .join(reservation.store, store) + .join(reservation.user, user) + .leftJoin(department) + .on(store.departmentId.eq(department.id)) + .leftJoin(storeImage) + .on( + storeImage.store.storeId.eq(store.storeId), + storeImage.imageType.eq(ImageType.PROFILE) + ) + .where( + reservation.user.id.eq(userId), + reservation.status.in( + ReservationStatus.WAITING, + ReservationStatus.CALLING + ), + reservation.requestedAt.between(startOfDay, endOfDay), + store.deleted.isFalse() + ) + .fetch(); + } +} 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 79ed84d5..15bff08b 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 @@ -13,7 +13,7 @@ import com.nowait.domaincorerdb.user.entity.User; @Repository -public interface ReservationRepository extends JpaRepository { +public interface ReservationRepository extends JpaRepository, ReservationCustomRepository { List findAllByStore_StoreIdOrderByRequestedAtAsc(Long storeId); boolean existsByUserAndStoreAndStatusIn(User user, Store store, List statuses);