Skip to content

Commit 4e900af

Browse files
authored
Merge pull request #279 from GTable/refactor#278-limit-waitingNum
Refactor: 유저별 웨이팅 최대 개수 3개로 제한
2 parents c37dfca + 4319eff commit 4e900af

11 files changed

Lines changed: 582 additions & 90 deletions

File tree

nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException;
3030
import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException;
3131
import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException;
32+
import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException;
3233
import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException;
34+
import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException;
35+
import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException;
3336
import com.nowait.domaincorerdb.store.exception.StoreNotFoundException;
3437
import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException;
3538
import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException;
@@ -244,6 +247,33 @@ public ErrorResponse handleStorePaymentNotFoundException(StorePaymentNotFoundExc
244247
return new ErrorResponse(e.getMessage(), STORE_PAYMENT_NOT_FOUND.getCode());
245248
}
246249

250+
@ResponseStatus(CONFLICT)
251+
@ExceptionHandler(UserWaitingLimitExceededException.class)
252+
public ErrorResponse handleUserWaitingLimitExceededException(
253+
UserWaitingLimitExceededException e, WebRequest request) {
254+
alarm(e, request);
255+
log.error("handleUserWaitingLimitExceededException", e);
256+
return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode());
257+
}
258+
259+
@ResponseStatus(INTERNAL_SERVER_ERROR)
260+
@ExceptionHandler(ReservationNumberIssueFailException.class)
261+
public ErrorResponse handleReservationNumberIssueFailException(
262+
ReservationNumberIssueFailException e, WebRequest request) {
263+
alarm(e, request);
264+
log.error("handleReservationNumberIssueFailException", e);
265+
return new ErrorResponse(e.getMessage(), RESERVATION_NUMBER_ISSUE_FAIL.getCode());
266+
}
267+
268+
@ResponseStatus(CONFLICT)
269+
@ExceptionHandler(ReservationAddUnauthorizedException.class)
270+
public ErrorResponse handleReservationAddUnauthorizedException(
271+
ReservationAddUnauthorizedException e, WebRequest request) {
272+
alarm(e, request);
273+
log.error("handleReservationAddUnauthorizedException", e);
274+
return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode());
275+
}
276+
247277
// 공통 에러 Map 생성
248278
private static Map<String, String> getErrors(MethodArgumentNotValidException e) {
249279
return e.getBindingResult()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.nowait.applicationuser.reservation.repository;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.time.Duration;
5+
import java.util.Collections;
6+
import java.util.HashSet;
7+
import java.util.Set;
8+
9+
import org.springframework.data.redis.connection.ReturnType;
10+
import org.springframework.data.redis.core.RedisCallback;
11+
import org.springframework.data.redis.core.StringRedisTemplate;
12+
import org.springframework.stereotype.Repository;
13+
14+
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;
15+
16+
import lombok.RequiredArgsConstructor;
17+
18+
@Repository
19+
@RequiredArgsConstructor
20+
public class WaitingPermitLuaRepository {
21+
22+
private final StringRedisTemplate redis;
23+
24+
private static final String ACQUIRE_SCRIPT =
25+
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]);" +
26+
"local holding = redis.call('ZCARD', KEYS[1]);" +
27+
"local active = redis.call('SCARD', KEYS[2]);" +
28+
"if (holding + active) >= tonumber(ARGV[3]) then return 0 end;" +
29+
"redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[2]), ARGV[4]);" +
30+
"return 1;";
31+
32+
private static final String FINALIZE_SCRIPT =
33+
"redis.call('ZREM', KEYS[1], ARGV[1]);" +
34+
"redis.call('SADD', KEYS[2], ARGV[2]);" +
35+
"return 1;";
36+
37+
public boolean acquireLease(String userId, String token, long nowMs, long leaseMs, int limit, Duration ttlTo3am) {
38+
final String hk = RedisKeyUtils.buildUserHoldingKey(userId); // u:{uid}:holding
39+
final String ak = RedisKeyUtils.buildUserActiveKey(userId); // u:{uid}:active
40+
41+
Long ok = redis.execute((RedisCallback<Long>) conn -> {
42+
Object res = conn.eval(
43+
ACQUIRE_SCRIPT.getBytes(StandardCharsets.UTF_8),
44+
ReturnType.INTEGER,
45+
2,
46+
raw(hk), raw(ak),
47+
raw(Long.toString(nowMs)),
48+
raw(Long.toString(leaseMs)),
49+
raw(Integer.toString(limit)),
50+
raw(token)
51+
);
52+
// TTL 정렬(스크립트 밖에서)
53+
conn.pExpire(raw(hk), ttlTo3am.toMillis());
54+
conn.pExpire(raw(ak), ttlTo3am.toMillis());
55+
return (Long) res;
56+
});
57+
return ok != null && ok == 1L;
58+
}
59+
60+
public void finalizeActive(String userId, String token, String storeId, String reservationId, Duration ttlTo3am) {
61+
final String hk = RedisKeyUtils.buildUserHoldingKey(userId);
62+
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
63+
final String member = storeId + ":" + reservationId;
64+
65+
redis.execute((RedisCallback<Void>) conn -> {
66+
conn.eval(
67+
FINALIZE_SCRIPT.getBytes(StandardCharsets.UTF_8),
68+
ReturnType.INTEGER,
69+
2,
70+
raw(hk), raw(ak),
71+
raw(token), raw(member)
72+
);
73+
conn.pExpire(raw(ak), ttlTo3am.toMillis());
74+
return null;
75+
});
76+
}
77+
78+
public void releaseLease(String userId, String token) {
79+
final String hk = RedisKeyUtils.buildUserHoldingKey(userId);
80+
redis.execute((RedisCallback<Void>) conn -> {
81+
conn.zRem(raw(hk), raw(token));
82+
return null;
83+
});
84+
}
85+
86+
public Set<String> getActiveMembers(String userId) {
87+
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
88+
return redis.execute((RedisCallback<Set<String>>) conn -> {
89+
Set<byte[]> raw = conn.sMembers(raw(ak));
90+
if (raw == null || raw.isEmpty()) return Collections.emptySet();
91+
Set<String> out = new HashSet<>(raw.size());
92+
for (byte[] b : raw) out.add(string(b));
93+
return out;
94+
});
95+
}
96+
97+
public void removeActiveMember(String userId, String storeId, String reservationId) {
98+
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
99+
final String member = storeId + ":" + reservationId;
100+
redis.execute((RedisCallback<Void>) conn -> {
101+
conn.sRem(raw(ak), raw(member));
102+
return null;
103+
});
104+
}
105+
106+
private byte[] raw(String s) { return redis.getStringSerializer().serialize(s); }
107+
private String string(byte[] b) { return redis.getStringSerializer().deserialize(b); }
108+
}

nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java

Lines changed: 121 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.nowait.applicationuser.reservation.service;
22

3+
import java.time.Duration;
34
import java.time.Instant;
45
import java.time.LocalDateTime;
56
import java.time.ZoneId;
@@ -18,17 +19,20 @@
1819
import org.springframework.transaction.annotation.Transactional;
1920

2021
import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto;
21-
import com.nowait.applicationuser.reservation.dto.MyWaitingStoreInfo;
2222
import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto;
2323
import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto;
2424
import com.nowait.applicationuser.reservation.dto.WaitingResponseDto;
25+
import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository;
2526
import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository;
2627
import com.nowait.common.enums.ReservationStatus;
2728
import com.nowait.common.enums.Role;
2829
import com.nowait.domaincorerdb.department.entity.Department;
2930
import com.nowait.domaincorerdb.department.repository.DepartmentRepository;
3031
import com.nowait.domaincorerdb.reservation.entity.Reservation;
3132
import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException;
33+
import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException;
34+
import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException;
35+
import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException;
3236
import com.nowait.domaincorerdb.reservation.repository.ReservationRepository;
3337
import com.nowait.domaincorerdb.store.entity.ImageType;
3438
import com.nowait.domaincorerdb.store.entity.Store;
@@ -55,41 +59,116 @@ public class ReservationService {
5559
private final WaitingUserRedisRepository waitingUserRedisRepository;
5660
private final DepartmentRepository departmentRepository;
5761
private final StoreImageRepository storeImageRepository;
62+
private final WaitingPermitLuaRepository waitingPermitLuaRepository;
5863
private final RedisTemplate redisTemplate;
59-
60-
public WaitingResponseDto registerWaiting(
61-
Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto
62-
) {
63-
// Store 유효성 검증 추가
64-
Store store = storeRepository.findById(storeId)
65-
.orElseThrow(StoreNotFoundException::new);
64+
private static final int USER_LIMIT = 3;
65+
private static final long LEASE_MS = 30_000; // 20~60초 권장
66+
67+
/**
68+
* 웨이팅 등록 기존 로직
69+
*/
70+
// public WaitingResponseDto registerWaiting(
71+
// Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto
72+
// ) {
73+
// // Store 유효성 검증 추가
74+
// Store store = storeRepository.findById(storeId)
75+
// .orElseThrow(StoreNotFoundException::new);
76+
// if (Boolean.FALSE.equals(store.getIsActive()))
77+
// throw new StoreWaitingDisabledException();
78+
//
79+
// // User Role 검증 추가
80+
// User user = userRepository.findById(customOAuth2User.getUserId())
81+
// .orElseThrow(UserNotFoundException::new);
82+
// if (user.getRole() == Role.MANAGER) {
83+
// throw new IllegalArgumentException("Manager cannot register waiting");
84+
// }
85+
//
86+
// String userId = customOAuth2User.getUserId().toString();
87+
// long timestamp = System.currentTimeMillis();
88+
//
89+
// // 예약 신청 유저 큐(queue)에 추가
90+
// String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(),
91+
// timestamp);
92+
// if (reservationId == null) {
93+
// throw new IllegalStateException("예약 번호 발급 실패");
94+
// }
95+
//
96+
// // 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환
97+
// Long rank = waitingUserRedisRepository.getRank(storeId, userId);
98+
// return WaitingResponseDto.builder()
99+
// .reservationNumber(reservationId)
100+
// .rank(rank == null ? -1 : rank.intValue() + 1)
101+
// .partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize())
102+
// .build();
103+
// }
104+
public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User principal,
105+
ReservationCreateRequestDto dto) {
106+
107+
// 0) 스토어/유저 검증 복구
108+
Store store = storeRepository.findById(storeId).orElseThrow(StoreNotFoundException::new);
66109
if (Boolean.FALSE.equals(store.getIsActive()))
67110
throw new StoreWaitingDisabledException();
68111

69-
// User Role 검증 추가
70-
User user = userRepository.findById(customOAuth2User.getUserId())
71-
.orElseThrow(UserNotFoundException::new);
72-
if (user.getRole() == Role.MANAGER) {
73-
throw new IllegalArgumentException("Manager cannot register waiting");
112+
User user = userRepository.findById(principal.getUserId()).orElseThrow(UserNotFoundException::new);
113+
if (user.getRole() == Role.MANAGER)
114+
throw new ReservationAddUnauthorizedException();
115+
116+
// (기존 유효성 검사 동일)
117+
String userId = user.getId().toString();
118+
Duration ttlTo3am = waitingUserRedisRepository.calculateTTLUntilNext03AM();
119+
120+
// 1) 이미 해당 store에 대기 중이면 임대 없이 현재 상태 반환 (중복 요청 허용)
121+
if (Boolean.TRUE.equals(waitingUserRedisRepository.isUserWaiting(storeId, userId))) {
122+
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
123+
Integer ps = waitingUserRedisRepository.getPartySize(storeId, userId);
124+
String reservationId = waitingUserRedisRepository.getReservationId(storeId, userId);
125+
return WaitingResponseDto.builder()
126+
.reservationNumber(reservationId)
127+
.rank(rank == null ? -1 : rank.intValue() + 1)
128+
.partySize(ps == null ? 0 : ps)
129+
.build();
74130
}
75131

76-
String userId = customOAuth2User.getUserId().toString();
77-
long timestamp = System.currentTimeMillis();
78-
79-
// 예약 신청 유저 큐(queue)에 추가
80-
String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(),
81-
timestamp);
82-
if (reservationId == null) {
83-
throw new IllegalStateException("예약 번호 발급 실패");
132+
// 1) 임대 획득
133+
String token = java.util.UUID.randomUUID().toString();
134+
int attempts = 0;
135+
while (true) {
136+
boolean ok = waitingPermitLuaRepository.acquireLease(userId, token, System.currentTimeMillis(), LEASE_MS,
137+
USER_LIMIT, ttlTo3am);
138+
if (ok)
139+
break;
140+
if (++attempts >= 3)
141+
throw new UserWaitingLimitExceededException();
142+
try {
143+
Thread.sleep((long)(5 * Math.pow(3, attempts - 1)));
144+
} catch (InterruptedException ignored) {
145+
}
84146
}
85147

86-
// 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환
87-
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
88-
return WaitingResponseDto.builder()
89-
.reservationNumber(reservationId)
90-
.rank(rank == null ? -1 : rank.intValue() + 1)
91-
.partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize())
92-
.build();
148+
String reservationId = null;
149+
try {
150+
// 2) 스토어 큐 등록(기존 메서드 그대로)
151+
long ts = System.currentTimeMillis();
152+
reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, dto.getPartySize(), ts);
153+
if (reservationId == null)
154+
throw new ReservationNumberIssueFailException();
155+
156+
// 3) 확정(holding→active)
157+
waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), reservationId, ttlTo3am);
158+
159+
// 4) 응답
160+
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
161+
return WaitingResponseDto.builder()
162+
.reservationNumber(reservationId)
163+
.rank(rank == null ? -1 : rank.intValue() + 1)
164+
.partySize(dto.getPartySize() == null ? 0 : dto.getPartySize())
165+
.build();
166+
167+
} catch (RuntimeException e) {
168+
// 실패 시 임대 반납
169+
waitingPermitLuaRepository.releaseLease(userId, token);
170+
throw e;
171+
}
93172
}
94173

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

141220
reservationRepository.save(reservation);
142221

143-
return removed;
222+
waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), reservationNumber);
223+
return true;
224+
// return removed;
144225
}
145226

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

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

235+
// 1) 현재 SCAN 기반으로 얻어온 storeId 리스트
236+
// List<Long> storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId);
237+
// if (storeIds.isEmpty())
238+
// return Collections.emptyList();
239+
List<Long> storeIds = members.stream()
240+
.map(m -> Long.parseLong(m.substring(0, m.indexOf(':'))))
241+
.distinct()
242+
.toList();
243+
155244
// 2) Store, Department 배치 조회
156245
List<Store> stores = storeRepository.findAllWithDepartmentByStoreIdIn(storeIds);
157246
Map<Long, Store> storeMap = stores.stream()

0 commit comments

Comments
 (0)