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 @@ -29,7 +29,10 @@
import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException;
import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException;
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;
Expand Down Expand Up @@ -244,6 +247,33 @@ public ErrorResponse handleStorePaymentNotFoundException(StorePaymentNotFoundExc
return new ErrorResponse(e.getMessage(), STORE_PAYMENT_NOT_FOUND.getCode());
}

@ResponseStatus(CONFLICT)
@ExceptionHandler(UserWaitingLimitExceededException.class)
public ErrorResponse handleUserWaitingLimitExceededException(
UserWaitingLimitExceededException e, WebRequest request) {
alarm(e, request);
log.error("handleUserWaitingLimitExceededException", e);
return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode());
}

@ResponseStatus(INTERNAL_SERVER_ERROR)
@ExceptionHandler(ReservationNumberIssueFailException.class)
public ErrorResponse handleReservationNumberIssueFailException(
ReservationNumberIssueFailException e, WebRequest request) {
alarm(e, request);
log.error("handleReservationNumberIssueFailException", e);
return new ErrorResponse(e.getMessage(), RESERVATION_NUMBER_ISSUE_FAIL.getCode());
}

@ResponseStatus(CONFLICT)
@ExceptionHandler(ReservationAddUnauthorizedException.class)
public ErrorResponse handleReservationAddUnauthorizedException(
ReservationAddUnauthorizedException e, WebRequest request) {
alarm(e, request);
log.error("handleReservationAddUnauthorizedException", e);
return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode());
}

// 공통 에러 Map 생성
private static Map<String, String> getErrors(MethodArgumentNotValidException e) {
return e.getBindingResult()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.nowait.applicationuser.reservation.repository;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;

import com.nowait.domaincoreredis.common.util.RedisKeyUtils;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class WaitingPermitLuaRepository {

private final StringRedisTemplate redis;

private static final String ACQUIRE_SCRIPT =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]);" +
"local holding = redis.call('ZCARD', KEYS[1]);" +
"local active = redis.call('SCARD', KEYS[2]);" +
"if (holding + active) >= tonumber(ARGV[3]) then return 0 end;" +
"redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[2]), ARGV[4]);" +
"return 1;";

private static final String FINALIZE_SCRIPT =
"redis.call('ZREM', KEYS[1], ARGV[1]);" +
"redis.call('SADD', KEYS[2], ARGV[2]);" +
"return 1;";

public boolean acquireLease(String userId, String token, long nowMs, long leaseMs, int limit, Duration ttlTo3am) {
final String hk = RedisKeyUtils.buildUserHoldingKey(userId); // u:{uid}:holding
final String ak = RedisKeyUtils.buildUserActiveKey(userId); // u:{uid}:active

Long ok = redis.execute((RedisCallback<Long>) conn -> {
Object res = conn.eval(
ACQUIRE_SCRIPT.getBytes(StandardCharsets.UTF_8),
ReturnType.INTEGER,
2,
raw(hk), raw(ak),
raw(Long.toString(nowMs)),
raw(Long.toString(leaseMs)),
raw(Integer.toString(limit)),
raw(token)
);
// TTL 정렬(스크립트 밖에서)
conn.pExpire(raw(hk), ttlTo3am.toMillis());
conn.pExpire(raw(ak), ttlTo3am.toMillis());
return (Long) res;
});
return ok != null && ok == 1L;
}

public void finalizeActive(String userId, String token, String storeId, String reservationId, Duration ttlTo3am) {
final String hk = RedisKeyUtils.buildUserHoldingKey(userId);
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
final String member = storeId + ":" + reservationId;

redis.execute((RedisCallback<Void>) conn -> {
conn.eval(
FINALIZE_SCRIPT.getBytes(StandardCharsets.UTF_8),
ReturnType.INTEGER,
2,
raw(hk), raw(ak),
raw(token), raw(member)
);
conn.pExpire(raw(ak), ttlTo3am.toMillis());
return null;
});
}

public void releaseLease(String userId, String token) {
final String hk = RedisKeyUtils.buildUserHoldingKey(userId);
redis.execute((RedisCallback<Void>) conn -> {
conn.zRem(raw(hk), raw(token));
return null;
});
}

public Set<String> getActiveMembers(String userId) {
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
return redis.execute((RedisCallback<Set<String>>) conn -> {
Set<byte[]> raw = conn.sMembers(raw(ak));
if (raw == null || raw.isEmpty()) return Collections.emptySet();
Set<String> out = new HashSet<>(raw.size());
for (byte[] b : raw) out.add(string(b));
return out;
});
}

public void removeActiveMember(String userId, String storeId, String reservationId) {
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
final String member = storeId + ":" + reservationId;
redis.execute((RedisCallback<Void>) conn -> {
conn.sRem(raw(ak), raw(member));
return null;
});
}

private byte[] raw(String s) { return redis.getStringSerializer().serialize(s); }
private String string(byte[] b) { return redis.getStringSerializer().deserialize(b); }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.nowait.applicationuser.reservation.service;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
Expand All @@ -18,17 +19,20 @@
import org.springframework.transaction.annotation.Transactional;

import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto;
import com.nowait.applicationuser.reservation.dto.MyWaitingStoreInfo;
import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto;
import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto;
import com.nowait.applicationuser.reservation.dto.WaitingResponseDto;
import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository;
import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository;
import com.nowait.common.enums.ReservationStatus;
import com.nowait.common.enums.Role;
import com.nowait.domaincorerdb.department.entity.Department;
import com.nowait.domaincorerdb.department.repository.DepartmentRepository;
import com.nowait.domaincorerdb.reservation.entity.Reservation;
import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException;
import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException;
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;
Expand All @@ -55,41 +59,116 @@ public class ReservationService {
private final WaitingUserRedisRepository waitingUserRedisRepository;
private final DepartmentRepository departmentRepository;
private final StoreImageRepository storeImageRepository;
private final WaitingPermitLuaRepository waitingPermitLuaRepository;
private final RedisTemplate redisTemplate;

public WaitingResponseDto registerWaiting(
Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto
) {
// Store 유효성 검증 추가
Store store = storeRepository.findById(storeId)
.orElseThrow(StoreNotFoundException::new);
private static final int USER_LIMIT = 3;
private static final long LEASE_MS = 30_000; // 20~60초 권장

/**
* 웨이팅 등록 기존 로직
*/
// public WaitingResponseDto registerWaiting(
// Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto
// ) {
// // Store 유효성 검증 추가
// Store store = storeRepository.findById(storeId)
// .orElseThrow(StoreNotFoundException::new);
// if (Boolean.FALSE.equals(store.getIsActive()))
// throw new StoreWaitingDisabledException();
//
// // User Role 검증 추가
// User user = userRepository.findById(customOAuth2User.getUserId())
// .orElseThrow(UserNotFoundException::new);
// if (user.getRole() == Role.MANAGER) {
// throw new IllegalArgumentException("Manager cannot register waiting");
// }
//
// String userId = customOAuth2User.getUserId().toString();
// long timestamp = System.currentTimeMillis();
//
// // 예약 신청 유저 큐(queue)에 추가
// String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(),
// timestamp);
// if (reservationId == null) {
// throw new IllegalStateException("예약 번호 발급 실패");
// }
//
// // 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환
// Long rank = waitingUserRedisRepository.getRank(storeId, userId);
// return WaitingResponseDto.builder()
// .reservationNumber(reservationId)
// .rank(rank == null ? -1 : rank.intValue() + 1)
// .partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize())
// .build();
// }
public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User principal,
ReservationCreateRequestDto dto) {

// 0) 스토어/유저 검증 복구
Store store = storeRepository.findById(storeId).orElseThrow(StoreNotFoundException::new);
if (Boolean.FALSE.equals(store.getIsActive()))
throw new StoreWaitingDisabledException();

// User Role 검증 추가
User user = userRepository.findById(customOAuth2User.getUserId())
.orElseThrow(UserNotFoundException::new);
if (user.getRole() == Role.MANAGER) {
throw new IllegalArgumentException("Manager cannot register waiting");
User user = userRepository.findById(principal.getUserId()).orElseThrow(UserNotFoundException::new);
if (user.getRole() == Role.MANAGER)
throw new ReservationAddUnauthorizedException();

// (기존 유효성 검사 동일)
String userId = user.getId().toString();
Duration ttlTo3am = waitingUserRedisRepository.calculateTTLUntilNext03AM();

// 1) 이미 해당 store에 대기 중이면 임대 없이 현재 상태 반환 (중복 요청 허용)
if (Boolean.TRUE.equals(waitingUserRedisRepository.isUserWaiting(storeId, userId))) {
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
Integer ps = waitingUserRedisRepository.getPartySize(storeId, userId);
String reservationId = waitingUserRedisRepository.getReservationId(storeId, userId);
return WaitingResponseDto.builder()
.reservationNumber(reservationId)
.rank(rank == null ? -1 : rank.intValue() + 1)
.partySize(ps == null ? 0 : ps)
.build();
}

String userId = customOAuth2User.getUserId().toString();
long timestamp = System.currentTimeMillis();

// 예약 신청 유저 큐(queue)에 추가
String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(),
timestamp);
if (reservationId == null) {
throw new IllegalStateException("예약 번호 발급 실패");
// 1) 임대 획득
String token = java.util.UUID.randomUUID().toString();
int attempts = 0;
while (true) {
boolean ok = waitingPermitLuaRepository.acquireLease(userId, token, System.currentTimeMillis(), LEASE_MS,
USER_LIMIT, ttlTo3am);
if (ok)
break;
if (++attempts >= 3)
throw new UserWaitingLimitExceededException();
try {
Thread.sleep((long)(5 * Math.pow(3, attempts - 1)));
} catch (InterruptedException ignored) {
}
}

// 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
return WaitingResponseDto.builder()
.reservationNumber(reservationId)
.rank(rank == null ? -1 : rank.intValue() + 1)
.partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize())
.build();
String reservationId = null;
try {
// 2) 스토어 큐 등록(기존 메서드 그대로)
long ts = System.currentTimeMillis();
reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, dto.getPartySize(), ts);
if (reservationId == null)
throw new ReservationNumberIssueFailException();

// 3) 확정(holding→active)
waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), reservationId, ttlTo3am);

// 4) 응답
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
return WaitingResponseDto.builder()
.reservationNumber(reservationId)
.rank(rank == null ? -1 : rank.intValue() + 1)
.partySize(dto.getPartySize() == null ? 0 : dto.getPartySize())
.build();

} catch (RuntimeException e) {
// 실패 시 임대 반납
waitingPermitLuaRepository.releaseLease(userId, token);
throw e;
}
}

public WaitingResponseDto myWaitingInfo(Long storeId, CustomOAuth2User customOAuth2User) {
Expand Down Expand Up @@ -140,18 +219,28 @@ public boolean cancelWaiting(Long storeId, CustomOAuth2User customOAuth2User) {

reservationRepository.save(reservation);

return removed;
waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), reservationNumber);
return true;
// return removed;
}

//TODO 성능 개선 필요
public List<MyWaitingQueueDto> getAllMyWaitings(CustomOAuth2User customOAuth2User) {
String userId = customOAuth2User.getUserId().toString();

// 1) 현재 SCAN 기반으로 얻어온 storeId 리스트
List<Long> storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId);
if (storeIds.isEmpty())
Set<String> members = waitingPermitLuaRepository.getActiveMembers(userId);
if (members.isEmpty())
return Collections.emptyList();

// 1) 현재 SCAN 기반으로 얻어온 storeId 리스트
// List<Long> storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId);
// if (storeIds.isEmpty())
// return Collections.emptyList();
List<Long> storeIds = members.stream()
.map(m -> Long.parseLong(m.substring(0, m.indexOf(':'))))
.distinct()
.toList();

// 2) Store, Department 배치 조회
List<Store> stores = storeRepository.findAllWithDepartmentByStoreIdIn(storeIds);
Map<Long, Store> storeMap = stores.stream()
Expand Down
Loading