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 @@ -23,4 +23,10 @@ public interface WaitingActivationPort {
// 대기열에서 제거 - 취소/만료 시 호출
void remove(UUID restaurantId, String token);

boolean tryAddUser(UUID restaurantId, UUID userId);

// 유저 플래그 제거 ( 취소/완료/만료 시 )
void removeUser(UUID restaurantId, UUID userId);


}
Original file line number Diff line number Diff line change
Expand Up @@ -46,29 +46,36 @@ public class WaitingService {
UUID.fromString("00000000-0000-0000-0000-000000000001");

// 대기 등록
public WaitingResult enterWaiting(EnterWaitingCommand command){
public WaitingResult enterWaiting(EnterWaitingCommand command) {

waitingRepository.findWaitingByRestaurantId(command.restaurantId())
.stream()
.filter(w -> w.getUserId().equals(command.userId()))
.findAny()
.ifPresent(w ->{throw new WaitingException(WaitingErrorCode.ALREADY_IN); });
// Redis SETNX로 원자적 중복 체크 + 플래그 저장
// false 반환 시 이미 대기 중인 유저
if (!waitingActivationPort.tryAddUser(command.restaurantId(), command.userId())) {
throw new WaitingException(WaitingErrorCode.ALREADY_IN);
}

Waiting waiting = Waiting.create(command.userId(), command.restaurantId());
try {
Waiting waiting = Waiting.create(command.userId(), command.restaurantId());

// DB 저장
Waiting saved = waitingRepository.save(waiting);
// DB 저장
Waiting saved = waitingRepository.save(waiting);

// redis 순번 등록
waitingActivationPort.add(command.restaurantId(), saved.getToken().value());
// redis 순번 조회
Long position = waitingActivationPort.getPosition(
command.restaurantId(), waiting.getToken().value()
);
// Redis 순번 등록
waitingActivationPort.add(command.restaurantId(), saved.getToken().value());

// Redis 순번 조회
Long position = waitingActivationPort.getPosition(
command.restaurantId(), waiting.getToken().value()
);

return WaitingResult.of(saved, position);
return WaitingResult.of(saved, position);

} catch (Exception e) {
// DB 저장 실패 시 Redis 유저 플래그 제거
// 재등록 가능하도록
waitingActivationPort.removeUser(command.restaurantId(), command.userId());
throw e;
}
}

// 상태 조회
Expand Down Expand Up @@ -99,6 +106,8 @@ public void cancelWaiting(UUID waitingId, UUID deletedBy){
waitingRepository.save(waiting);
waitingRepository.softDelete(waitingId, deletedBy);
waitingActivationPort.remove(waiting.getRestaurantId(), waiting.getToken().value());
// 취소 후 재등록 가능하도록 플래그 제거
waitingActivationPort.removeUser(waiting.getRestaurantId(), waiting.getUserId());
}

// ACTIVE 상태인지 검증 - 예약 서비스가 예약 전 호출
Expand Down Expand Up @@ -143,6 +152,9 @@ public void activateNextBatch(UUID restaurantId){
List<ScoredToken> scoredTokens = waitingActivationPort.popNextTokensWithScore(restaurantId,batchSize);

for(ScoredToken scoredToken : scoredTokens){

boolean activeSaved = false;

try{
Optional<Waiting> waitingOpt = waitingRepository.findByToken(scoredToken.token());
if (waitingOpt.isEmpty()) {
Expand Down Expand Up @@ -170,16 +182,23 @@ public void activateNextBatch(UUID restaurantId){
waiting.activate();
waitingRepository.save(waiting);

activeSaved = true;

// ACTIVE 전환 후 예약 완료 시 재등록 가능하도록 플래그 제거
waitingActivationPort.removeUser(restaurantId, waiting.getUserId());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 3. 동일한 Outbox 인스턴스 PROCESSED 로 update
outbox.markProcessed(LocalDateTime.now());
waitingOutboxRepository.update(outbox);
}catch (Exception e){
// 4. DB 저장 실패 시 원래 score로 Redis 복구
waitingActivationPort.addWithScore(
restaurantId,
scoredToken.token(),
scoredToken.score()
);
if (!activeSaved) {
waitingActivationPort.addWithScore(
restaurantId,
scoredToken.token(),
scoredToken.score()
);
}

log.warn("[스케줄러] ACTIVE 전환 실패 Redis 복구 - token : {}",
scoredToken.token(),e);
Expand All @@ -194,8 +213,16 @@ public void expireWaitings(){
expired.forEach(waiting -> {
waiting.expire();
waitingRepository.save(waiting);
waitingActivationPort.remove(waiting.getRestaurantId(), waiting.getToken().value());
waitingActivationPort.remove(
waiting.getRestaurantId(),
waiting.getToken().value());
waitingActivationPort.removeUser(
waiting.getRestaurantId(),
waiting.getUserId()
);
});


}

public void retryPendingOutbox(){
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.michelet.waiting.domain.repository;

import com.michelet.waiting.domain.entity.Waiting;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
Expand All @@ -13,7 +12,6 @@ public interface WaitingRepository {
Optional<Waiting> findByToken(String token);
Optional<Waiting> findById(UUID id);
Optional<Waiting> findByAccessToken(String accessToken);
List<Waiting> findWaitingByRestaurantId(UUID restaurantId);
List<Waiting> findExpiredActives(LocalDateTime expiredBefore);
List<UUID> findDistinctRestaurantIdsWithWaiting();
void softDelete(UUID waitingId, UUID deletedBy);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package com.michelet.waiting.infrastructure.persistence.jpa;

import com.michelet.waiting.domain.entity.Waiting;
import com.michelet.waiting.domain.enums.WaitingStatus;
import com.michelet.waiting.domain.repository.WaitingRepository;
import com.michelet.waiting.infrastructure.persistence.querydsl.WaitingQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
Expand Down Expand Up @@ -42,14 +40,6 @@ public Optional<Waiting> findByAccessToken(String accessToken) {
.map(WaitingJpaEntity::toDomain);
}

@Override
public List<Waiting> findWaitingByRestaurantId(UUID restaurantId) {
return jpa.findByRestaurantIdAndStatusAndDeletedAtIsNull(restaurantId, WaitingStatus.WAITING)
.stream()
.map(WaitingJpaEntity::toDomain)
.toList();
}

@Override
public List<Waiting> findExpiredActives(LocalDateTime expiredBefore) {
return queryRepository.findExpiredActives(expiredBefore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
public class RedisWaitingActivationAdapter implements WaitingActivationPort {

private final RedisTemplate<String,String> redisTemplate;
private static final String USER_PREFIX = "waiting:user:";
private static final String PREFIX = "waiting:queue:";
private static final String SEQ_PREFIX = "waiting:seq:";

Expand All @@ -35,6 +36,15 @@ private String buildSeqKey(UUID restaurantId){
return SEQ_PREFIX + restaurantId;
}

// userKey create - "waiting:user:{restaurantId}:{userId}"
// 유저별 식당 대기 등록 여부 관리
private String buildUserKey(UUID restaurantId, UUID userId) {
Objects.requireNonNull(restaurantId, "restaurantId must not be null");
Objects.requireNonNull(userId, "userId must not be null");
return USER_PREFIX + restaurantId + ":" + userId;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.


private void validateToken(String token){
if(token == null || token.isBlank())
throw new WaitingException(WaitingErrorCode.INVALID_TOKEN);
Expand Down Expand Up @@ -108,4 +118,21 @@ public void remove(UUID restaurantId, String token) {
validateToken(token);
redisTemplate.opsForZSet().remove(buildKey(restaurantId), token);
}

@Override
public boolean tryAddUser(UUID restaurantId, UUID userId) {
Objects.requireNonNull(restaurantId, "restaurantId must not be null");
Objects.requireNonNull(userId, "userId must not be null");
String key = buildUserKey(restaurantId, userId);
// SETNX — 키가 없을 때만 저장, 있으면 false 반환
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1");
return Boolean.TRUE.equals(result);
}

// 유저 플래그 제거
// 취소/완료/만료/ACTIVE 전환 시 호출
@Override
public void removeUser(UUID restaurantId, UUID userId) {
redisTemplate.delete(buildUserKey(restaurantId, userId));
}
}
Loading