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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.springframework.web.bind.annotation.RestController;

import com.nowait.applicationadmin.reservation.dto.CallingWaitingResponseDto;
import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto;
import com.nowait.applicationadmin.reservation.dto.WaitingUserResponse;
import com.nowait.applicationadmin.reservation.service.ReservationService;
import com.nowait.common.api.ApiUtils;
Expand Down Expand Up @@ -51,21 +52,21 @@ public ResponseEntity<?> getCompletedReservationList(
return ResponseEntity.ok(response);
}

@PatchMapping("/admin/{storeId}/call/{userId}")
@Operation(summary = "예약팀 호출", description = "특정 예약에 대한 호출 진행(호출하는 순간 10분 타임어택)")
@ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : WAITING -> CALLING")
public ResponseEntity<?> callWaiting(@PathVariable Long storeId,
@PathVariable String userId,
@AuthenticationPrincipal MemberDetails memberDetails) {
CallingWaitingResponseDto response = reservationService.callWaiting(storeId, userId, memberDetails);
return ResponseEntity
.status(HttpStatus.OK)
.body(
ApiUtils.success(
response
)
);
}
// @PatchMapping("/admin/{storeId}/call/{userId}")
// @Operation(summary = "예약팀 호출", description = "특정 예약에 대한 호출 진행(호출하는 순간 10분 타임어택)")
// @ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : WAITING -> CALLING")
// public ResponseEntity<?> callWaiting(@PathVariable Long storeId,
// @PathVariable String userId,
// @AuthenticationPrincipal MemberDetails memberDetails) {
// CallingWaitingResponseDto response = reservationService.callWaiting(storeId, userId, memberDetails);
// return ResponseEntity
// .status(HttpStatus.OK)
// .body(
// ApiUtils.success(
// response
// )
// );
// }
@PatchMapping("/admin/update/{storeId}/{userId}/{status}")
@Operation(summary = "예약팀 상태 업데이트 처리", description = "특정 예약에 대한 입장 완료 처리")
@ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : CALLING -> CONFIRMED")
Expand All @@ -75,7 +76,7 @@ public ResponseEntity<?> updateEntry(
@PathVariable ReservationStatus status,
@AuthenticationPrincipal MemberDetails memberDetails
) {
String response = reservationService.processEntryStatus(storeId, userId, memberDetails,status);
EntryStatusResponseDto response = reservationService.processEntryStatus(storeId, userId, memberDetails,status);
return ResponseEntity
.status(HttpStatus.OK)
.body(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.nowait.applicationadmin.reservation.dto;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

// DTO: 호출 시각(calledAt) 또는 완료 메시지(message)를 선택적으로 담습니다.
@Getter
@Builder
public class EntryStatusResponseDto {
@Schema(description = "예약 ID", example = "1201")
private String id; // reservationId

@Schema(description = "유저 ID", example = "16")
private String userId;

@Schema(description = "파티 인원", example = "3")
private Integer partySize;

@Schema(description = "사용자 이름(닉네임)", example = "혜민이")
private String userName;

@Schema(description = "대기 등록 시각", example = "2025-07-22T16:00:00")
private LocalDateTime createdAt;

@Schema(description = "대기 상태", example = "CALLING")
private String status;

@Schema(description = "대기 순번/점수", example = "2.0")
private Double score;

@Schema(description = "호출 메시지", example = "호출 메시지")
private String message;
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.nowait.applicationadmin.reservation.service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
Expand All @@ -11,6 +13,7 @@

import com.nowait.applicationadmin.reservation.dto.CallGetResponseDto;
import com.nowait.applicationadmin.reservation.dto.CallingWaitingResponseDto;
import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto;
import com.nowait.applicationadmin.reservation.dto.ReservationGetResponseDto;
import com.nowait.applicationadmin.reservation.dto.ReservationStatusSummaryDto;
import com.nowait.applicationadmin.reservation.dto.ReservationStatusUpdateRequestDto;
Expand All @@ -23,6 +26,7 @@
import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.ReservationViewUnauthorizedException;
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.MemberDetails;
import com.nowait.domaincorerdb.user.entity.User;
Expand Down Expand Up @@ -132,59 +136,112 @@ public List<WaitingUserResponse> getCompletedWaitingUserDetails(Long storeId) {
.toList();
}

// 대기 객체 호출 (WAITING -> CALLING)
@Transactional
public CallingWaitingResponseDto callWaiting(Long storeId, String userId, MemberDetails memberDetails) {
User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new);
if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(storeId)) {

private User authorize(Long storeId, MemberDetails member) {
User u = userRepository.findById(member.getId())
.orElseThrow(UserNotFoundException::new);
if (!Role.SUPER_ADMIN.equals(u.getRole()) && !storeId.equals(u.getStoreId())) {
throw new ReservationViewUnauthorizedException();
}
String status = waitingRedisRepository.getWaitingStatus(storeId, userId);
if (!"WAITING".equals(status)) {
throw new IllegalStateException("이미 호출되었거나 없는 예약입니다.");
}
Integer partySize = waitingRedisRepository.getWaitingPartySize(storeId, userId);
waitingRedisRepository.setWaitingStatus(storeId, userId, "CALLING");
// [2] DB에 상태 영구 저장 (없으면 생성, 있으면 상태만 변경)
Reservation reservation = reservationRepository.findByStore_StoreIdAndUserId(storeId, Long.valueOf(userId))
.orElse(
Reservation.builder()
.store(storeRepository.getReferenceById(storeId))
.user(user)
.requestedAt(LocalDateTime.now())
.partySize(partySize)
.build()
);
reservation.updateStatus(ReservationStatus.CALLING); // setter 대신 빌더로 새 객체 or withStatus 패턴 추천
reservationRepository.save(reservation);
return CallingWaitingResponseDto.builder()
.storeId(storeId)
.userId(userId)
.status(reservation.getStatus().name())
.calledAt(reservation.getRequestedAt())
.build();
return u;
}

// 공통: 오늘 날짜 예약 조회
private Reservation findTodayReservation(Long storeId, String userId) {
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);

return reservationRepository
.findByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetween(
storeId,
Long.valueOf(userId),
List.of(ReservationStatus.WAITING, ReservationStatus.CALLING),
startOfDay,
endOfDay
)
.orElseThrow(() -> new IllegalArgumentException("오늘 날짜의 예약이 존재하지 않습니다."));
}
Comment on lines +150 to 163
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

당일 예약 조회 로직의 잠재적 문제

findTodayReservation 메서드에서 리포지토리의 findByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetween 메서드를 사용하고 있는데, 이 메서드는 Optional<Reservation>을 반환하므로 동일 조건의 복수 예약 존재 시 NonUniqueResultException이 발생할 수 있습니다.

또한 158라인의 상태 필터가 WAITING, CALLING으로 제한되어 있어, 다른 상태의 당일 예약은 조회되지 않습니다.

리포지토리 메서드 수정이 필요하거나, 비즈니스 로직상 복수 예약이 가능하다면 다음과 같이 수정을 고려하세요:

-		return reservationRepository
-			.findByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetween(
-				storeId,
-				Long.valueOf(userId),
-				List.of(ReservationStatus.WAITING, ReservationStatus.CALLING),
-				startOfDay,
-				endOfDay
-			)
-			.orElseThrow(() -> new IllegalArgumentException("오늘 날짜의 예약이 존재하지 않습니다."));
+		List<Reservation> reservations = reservationRepository
+			.findByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetween(
+				storeId,
+				Long.valueOf(userId),
+				List.of(ReservationStatus.WAITING, ReservationStatus.CALLING),
+				startOfDay,
+				endOfDay
+			);
+		if (reservations.isEmpty()) {
+			throw new IllegalArgumentException("오늘 날짜의 예약이 존재하지 않습니다.");
+		}
+		return reservations.get(0); // 또는 적절한 선택 로직

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/service/ReservationService.java
around lines 150 to 163, the method findTodayReservation uses a repository
method that returns Optional<Reservation>, which can cause
NonUniqueResultException if multiple reservations match the criteria. Also, the
status filter only includes WAITING and CALLING, excluding other possible
statuses. To fix this, modify the repository method to return a list of
reservations instead of Optional, or adjust the business logic to handle
multiple reservations. Update the method to retrieve all matching reservations
for the day and handle them appropriately, such as returning a list or selecting
one based on additional criteria.

// 대기 객체 상태 변경

/**
* 상태를 하나의 메서드에서 처리합니다.
* - CALLING : Redis 상태를 CALLING으로 변경 → DB 저장 → calledAt 반환
* - CONFIRMED : Redis에서 삭제 → DB 저장 → 완료 메시지 반환
* - CANCELLED : Redis에서 삭제 → DB 저장 → 취소 메시지 반환
*/
@Transactional
public String processEntryStatus(Long storeId, String userId, MemberDetails memberDetails, ReservationStatus status) {
// (권한 체크 필요시 여기에 추가)
User user = userRepository.findById(memberDetails.getId())
.orElseThrow(UserNotFoundException::new);
if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(storeId)) {
throw new ReservationViewUnauthorizedException();
public EntryStatusResponseDto processEntryStatus(
Long storeId,
String userId,
MemberDetails member,
ReservationStatus newStatus
) {
User manager = authorize(storeId, member);
User user = userRepository.findById(Long.valueOf(userId)).orElseThrow(UserNotFoundException::new);

String message = null;
Reservation reservation;

switch (newStatus) {
case CALLING:
// 1) Redis 상태 검사 & 변경
String curr = waitingRedisRepository.getWaitingStatus(storeId, userId);
if (!ReservationStatus.WAITING.name().equals(curr)) {
throw new IllegalStateException("이미 호출되었거나 없는 예약입니다.");
}
waitingRedisRepository.setWaitingStatus(storeId, userId, ReservationStatus.CALLING.name());

// 2) 파티 인원, 호출 시각
Integer partySize = waitingRedisRepository.getWaitingPartySize(storeId, userId);
LocalDateTime now = LocalDateTime.now();

// 3) DB에 무조건 새로 저장
Store store = storeRepository.getReferenceById(storeId);
reservation = Reservation.builder()
.store(store)
.user(user)
.partySize(partySize)
.requestedAt(now)
.status(ReservationStatus.CALLING)
.build();
reservationRepository.save(reservation);

break;

case CONFIRMED:
case CANCELLED:
// 1) Redis에서 제거
waitingRedisRepository.deleteWaiting(storeId, userId);

// 2) 오늘 날짜 예약 조회 & 상태 변경
reservation = findTodayReservation(storeId, userId);
reservation.updateStatus(newStatus);
reservationRepository.save(reservation);

// 3) 완료/취소 메시지
message = String.format(
"%s님의 예약이 %s 처리되었습니다.",
user.getNickname(),
newStatus == ReservationStatus.CONFIRMED ? "입장 완료" : "입장 취소"
);
break;

default:
throw new IllegalArgumentException("지원하지 않는 상태입니다: " + newStatus);
}
// 1. DB status 업데이트
Reservation reservation = reservationRepository.findByStore_StoreIdAndUserId(storeId, Long.valueOf(userId))
.orElseThrow(() -> new IllegalArgumentException("해당 예약이 존재하지 않습니다."));
reservation.updateStatus(status);
// 2. Redis에서 삭제
waitingRedisRepository.deleteWaiting(storeId, userId);

// 메시지 동적 반환
String action = (status == ReservationStatus.CONFIRMED) ? "입장 완료" : "입장 취소";
return user.getNickname() + "님의 예약이 " + action + " 처리되었습니다.";

// 5) 공통 DTO 반환
return EntryStatusResponseDto.builder()
.id( reservation.getId().toString())
.userId( userId)
.partySize( reservation.getPartySize())
.userName( user.getNickname())
.createdAt( reservation.getRequestedAt())
.status( reservation.getStatus().name())
.message( message)
.build();
}
Comment on lines +172 to 242
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

상태 처리 통합 메서드의 로직 검증 필요

processEntryStatus 메서드는 여러 상태를 통합 처리하는 좋은 리팩토링이지만, 몇 가지 검토가 필요합니다:

  1. CALLING 상태 처리 (184-208라인): 새로운 Reservation 엔티티를 무조건 생성하고 있는데, 기존 예약과의 관계나 중복 처리 로직이 명확하지 않습니다.

  2. 트랜잭션 일관성: Redis 상태 변경과 DB 저장 사이의 실패 시나리오에 대한 롤백 처리가 고려되지 않았습니다.

  3. 동시성 문제: Redis 상태 확인(187라인)과 변경(191라인) 사이의 race condition 가능성이 있습니다.

다음과 같은 개선을 고려해보세요:

case CALLING:
	// 1) Redis 상태 검사 & 변경을 원자적으로 처리
-	String curr = waitingRedisRepository.getWaitingStatus(storeId, userId);
-	if (!ReservationStatus.WAITING.name().equals(curr)) {
-		throw new IllegalStateException("이미 호출되었거나 없는 예약입니다.");
-	}
-	waitingRedisRepository.setWaitingStatus(storeId, userId, ReservationStatus.CALLING.name());
+	if (!waitingRedisRepository.compareAndSetWaitingStatus(storeId, userId, 
+		ReservationStatus.WAITING.name(), ReservationStatus.CALLING.name())) {
+		throw new IllegalStateException("이미 호출되었거나 없는 예약입니다.");
+	}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/service/ReservationService.java
between lines 172 and 242, the processEntryStatus method creates a new
Reservation entity unconditionally for CALLING status without checking for
existing reservations, risking duplicates. To fix this, add logic to check for
an existing reservation before creating a new one and update it if necessary.
Also, wrap the Redis status update and database save operations in a single
transaction or use compensating actions to ensure consistency on failure.
Finally, to prevent race conditions between Redis status check and update,
implement atomic operations or locking mechanisms around these Redis calls.




}

Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ public class ReservationController {
private final ReservationService reservationService;

@PostMapping("/create/{storeId}")
@Operation(summary = "예약 생성", description = "특정 주점에 대한 예약하기 생성")
@ApiResponse(responseCode = "201", description = "예약 생성")
public ResponseEntity<?> create(
@PathVariable Long storeId,
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -12,6 +13,7 @@
import com.nowait.applicationuser.store.dto.StoreWaitingInfo;
import com.nowait.applicationuser.store.service.StoreService;
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.responses.ApiResponse;
Expand Down Expand Up @@ -48,12 +50,12 @@ public ResponseEntity<?> getAllStoresByPageAndDeparments(Pageable pageable) {
@GetMapping("/{storeId}")
@Operation(summary = "주점 ID로 주점 상세 조회", description = "특정 주점을 ID로 조회합니다.")
@ApiResponse(responseCode = "200", description = "주점 상세 조회 성공")
public ResponseEntity<?> getStoreById(@PathVariable Long storeId) {
public ResponseEntity<?> getStoreById(@PathVariable Long storeId, @AuthenticationPrincipal CustomOAuth2User customOAuth2User) {
return ResponseEntity
.status(HttpStatus.OK)
.body(
ApiUtils.success(
storeService.getStoreByStoreId(storeId)
storeService.getStoreByStoreId(storeId, customOAuth2User)
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class StorePageReadDto {
private Boolean isActive;
private Boolean deleted;
private LocalDateTime createdAt;
private Boolean isBookmarked;

public static StorePageReadDto fromEntity(Store store, List<StoreImageUploadResponse> allImages, String departmentName, Long waitingCount) {

Expand All @@ -51,6 +52,37 @@ public static StorePageReadDto fromEntity(Store store, List<StoreImageUploadResp
.deleted(store.getDeleted())
.profileImage(profile)
.bannerImages(banners)
.isBookmarked(false)
.build();
}

public static StorePageReadDto fromEntityWithBookmark(
Store store, List<StoreImageUploadResponse> allImages, String departmentName, Long waitingCount, Boolean isBookmarked
) {

StoreImageUploadResponse profile = allImages.stream()
.filter(image -> image.getImageType() == ImageType.PROFILE)
.findFirst()
.orElse(null);

List<StoreImageUploadResponse> banners = allImages.stream()
.filter(image -> image.getImageType() == ImageType.BANNER)
.toList();

return StorePageReadDto.builder()
.createdAt(store.getCreatedAt())
.storeId(store.getStoreId())
.waitingCount(waitingCount)
.departmentId(store.getDepartmentId())
.departmentName(departmentName)
.name(store.getName())
.location(store.getLocation())
.description(store.getDescription())
.isActive(store.getIsActive())
.deleted(store.getDeleted())
.profileImage(profile)
.bannerImages(banners)
.isBookmarked(isBookmarked)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
import com.nowait.applicationuser.store.dto.StoreReadDto;
import com.nowait.applicationuser.store.dto.StoreReadResponse;
import com.nowait.applicationuser.store.dto.StoreWaitingInfo;
import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User;

public interface StoreService {

StoreDepartmentReadResponse getAllStoresByPageAndDeparments(Pageable pageable);

StorePageReadDto getStoreByStoreId(Long storeId);
StorePageReadDto getStoreByStoreId(Long storeId, CustomOAuth2User customOAuth2User);

List<StorePageReadDto> searchByKeywordNative(String name);

Expand Down
Loading