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
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ dependencies {
// Test Dependencies
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testRuntimeOnly("com.h2database:h2")

// Documentation
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,18 @@ public void clearCompletionCount() {
}

/**
* 예약 타임아웃 초과 여부를 반환합니다.
* 예약 타임아웃 초과 여부를 반환합니다. 타임아웃 유무와 길이는 {@link ReservationStatus}의 설정을 그대로 따르므로,
* 상태별 타임아웃을 바꾸려면 열거형만 수정하면 됩니다.
*
* @return 타임아웃 초과 여부
*/
public boolean isExpired() {
LocalDateTime now = DateTimeUtil.nowInKorea();
if (!this.status.hasTimeout()) {
return false;
}

return switch (this.status) {
case RESERVED ->
Duration.between(this.reservedAt, now).toMinutes() >= ReservationStatus.RESERVED.getTimeoutMinutes();
default -> false;
};
return Duration.between(this.reservedAt, DateTimeUtil.nowInKorea()).toMinutes() >= this.status
.getTimeoutMinutes();
}

/**
Expand All @@ -146,16 +146,14 @@ public boolean isExpired() {
* @return 타임아웃까지 남은 시간
*/
public Duration getRemainingTimeUntilTimeout() {
LocalDateTime now = DateTimeUtil.nowInKorea();

return switch (this.status) {
case RESERVED -> {
long minutes = ReservationStatus.RESERVED.getTimeoutMinutes()
- Duration.between(this.reservedAt, now).toMinutes();
yield Duration.ofMinutes(Math.max(0, minutes));
}
default -> Duration.ZERO;
};
if (!this.status.hasTimeout()) {
return Duration.ZERO;
}

final long remainingMinutes = this.status.getTimeoutMinutes()
- Duration.between(this.reservedAt, DateTimeUtil.nowInKorea()).toMinutes();

return Duration.ofMinutes(Math.max(0, remainingMinutes));
}

/**
Expand Down Expand Up @@ -247,6 +245,19 @@ public boolean isActive() {
return this.status == ReservationStatus.RESERVED || this.status == ReservationStatus.RUNNING;
}

/**
* 만료되지 않은 현재 활성 예약인지 반환합니다.
*
* <p>
* 활성 상태이더라도 타임아웃이 지난 RESERVED 예약은 활성으로 세지 않습니다. 스케줄러가 아직 정리하지 못한 만료 예약이 새 예약을
* 막거나 조회 결과에 노출되는 것을 방지하기 위함입니다.
*
* @return 만료되지 않은 활성 예약 여부
*/
public boolean isCurrentlyActive() {
return isActive() && !isExpired();
}

/**
* 관리자 대리 생성 예약 여부를 반환합니다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,11 @@ public interface ReservationRepository extends JpaRepository<Reservation, Long>,

List<Reservation> findByStatusIn(List<ReservationStatus> statuses);

@Query("SELECT r FROM Reservation r WHERE r.user = :user AND r.status IN :statuses")
List<Reservation> findByUserAndStatusIn(@Param("user") User user,
@Param("statuses") List<ReservationStatus> statuses);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Reservation r JOIN FETCH r.machine WHERE r.user = :user AND r.status IN :statuses")
List<Reservation> findByUserAndStatusInForUpdate(@Param("user") User user,
@Param("statuses") List<ReservationStatus> statuses);

@Query("SELECT r FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses")
List<Reservation> findByMachineAndStatusIn(@Param("machine") Machine machine,
@Param("statuses") List<ReservationStatus> statuses);

default List<Reservation> findAllActiveReservations() {
return findByStatusIn(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import team.washer.server.v2.domain.machine.entity.Machine;
import team.washer.server.v2.domain.machine.enums.MachineType;
import team.washer.server.v2.domain.reservation.entity.Reservation;
import team.washer.server.v2.domain.reservation.enums.ReservationStatus;
import team.washer.server.v2.domain.user.entity.User;

public interface ReservationRepositoryCustom {

Expand Down Expand Up @@ -56,13 +58,31 @@ Page<Reservation> findAllWithFilters(String userName,
Pageable pageable);

/**
* 호실 번호 기준 활성 예약 목록 조회
* 사용자의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다.
*
* @param user
* 조회 대상 사용자
* @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순)
*/
List<Reservation> findCurrentlyActiveByUser(User user);

/**
* 기기의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다.
*
* @param machine
* 조회 대상 기기
* @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순)
*/
List<Reservation> findCurrentlyActiveByMachine(Machine machine);

/**
* 호실의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다.
*
* @param roomNumber
* 호실 번호
* @return 해당 호실의 활성(RESERVED/RUNNING) 예약 목록 (createdAt 내림차순)
* @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순)
*/
List<Reservation> findActiveReservationsByRoomNumber(String roomNumber);
List<Reservation> findCurrentlyActiveByRoomNumber(String roomNumber);

/**
* 기기별 예약 히스토리 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;
import team.washer.server.v2.domain.machine.entity.Machine;
import team.washer.server.v2.domain.machine.enums.MachineType;
import team.washer.server.v2.domain.reservation.entity.QReservation;
import team.washer.server.v2.domain.reservation.entity.Reservation;
import team.washer.server.v2.domain.reservation.enums.ReservationStatus;
import team.washer.server.v2.domain.reservation.repository.custom.ReservationRepositoryCustom;
import team.washer.server.v2.domain.user.entity.QUser;
import team.washer.server.v2.domain.user.entity.User;
import team.washer.server.v2.global.util.DateTimeUtil;

@Repository
@RequiredArgsConstructor
Expand All @@ -34,6 +37,9 @@ public class ReservationRepositoryCustomImpl implements ReservationRepositoryCus
// QUser 기본 별칭(user)은 reservation.user 조인에 이미 사용되므로 대리 예약 생성자용 별칭을 따로 둔다
private static final QUser createdByUser = new QUser("createdByUser");

private static final List<ReservationStatus> ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
ReservationStatus.RUNNING);

private final JPAQueryFactory jpaQueryFactory;

@Override
Expand Down Expand Up @@ -83,14 +89,65 @@ public boolean existsConflictingReservation(Long machineId,
}

@Override
public List<Reservation> findActiveReservationsByRoomNumber(String roomNumber) {
public List<Reservation> findCurrentlyActiveByUser(User targetUser) {
return jpaQueryFactory.selectFrom(reservation).join(reservation.user, user).fetchJoin()
.join(reservation.machine, machine).fetchJoin()
.where(reservation.user.roomNumber.eq(roomNumber),
reservation.status.in(ReservationStatus.RESERVED, ReservationStatus.RUNNING))
.where(reservation.user.eq(targetUser), currentlyActive()).orderBy(reservation.createdAt.desc())
.fetch();
}

@Override
public List<Reservation> findCurrentlyActiveByMachine(Machine targetMachine) {
return jpaQueryFactory.selectFrom(reservation).join(reservation.machine, machine).fetchJoin()
.where(reservation.machine.eq(targetMachine), currentlyActive()).orderBy(reservation.createdAt.desc())
.fetch();
}

@Override
public List<Reservation> findCurrentlyActiveByRoomNumber(String roomNumber) {
return jpaQueryFactory.selectFrom(reservation).join(reservation.user, user).fetchJoin()
.join(reservation.machine, machine).fetchJoin()
.where(reservation.user.roomNumber.eq(roomNumber), currentlyActive())
.orderBy(reservation.createdAt.desc()).fetch();
}

/**
* 만료되지 않은 활성 예약 조건을 반환합니다. {@link Reservation#isCurrentlyActive()}와 동일한 규칙을 쿼리
* 조건으로 표현한 것으로, 전체를 로드한 뒤 메모리에서 거르지 않도록 합니다.
*
* <p>
* 타임아웃 유무와 길이는 엔티티와 마찬가지로 {@link ReservationStatus}의 설정에서 파생되므로, 상태별 타임아웃을 바꾸면
* 양쪽 판정이 함께 따라옵니다.
*
* @return 만료되지 않은 활성 예약 조건
*/
private BooleanExpression currentlyActive() {
final LocalDateTime now = DateTimeUtil.nowInKorea();

return ACTIVE_STATUSES.stream().map(status -> notExpired(status, now)).reduce(BooleanExpression::or)
.orElseThrow();
}

/**
* 특정 상태의 만료되지 않은 예약 조건을 반환합니다. 타임아웃이 없는 상태는 상태 일치만으로 통과시키고, 타임아웃이 있는 상태는 컷오프
* 이후에 예약된 건만 남깁니다.
*
* @param status
* 판정 대상 예약 상태
* @param now
* 컷오프 계산 기준 시각
* @return 해당 상태의 만료되지 않은 예약 조건
*/
private BooleanExpression notExpired(final ReservationStatus status, final LocalDateTime now) {
final BooleanExpression statusMatches = reservation.status.eq(status);

if (!status.hasTimeout()) {
return statusMatches;
}

return statusMatches.and(reservation.reservedAt.gt(now.minusMinutes(status.getTimeoutMinutes())));
}

@Override
public List<Reservation> findExpiredReservations(ReservationStatus status,
LocalDateTime threshold,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package team.washer.server.v2.domain.reservation.service.impl;

import java.util.Comparator;
import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -11,7 +8,6 @@
import team.themoment.sdk.exception.ExpectedException;
import team.washer.server.v2.domain.reservation.dto.response.ReservationResDto;
import team.washer.server.v2.domain.reservation.entity.Reservation;
import team.washer.server.v2.domain.reservation.enums.ReservationStatus;
import team.washer.server.v2.domain.reservation.repository.ReservationRepository;
import team.washer.server.v2.domain.reservation.service.QueryActiveReservationService;
import team.washer.server.v2.domain.user.entity.User;
Expand All @@ -33,11 +29,9 @@ public ReservationResDto execute() {
final User user = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND));

final List<Reservation> activeReservations = reservationRepository.findByUserAndStatusIn(user,
List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING));

final Reservation latest = activeReservations.stream().filter(r -> !r.isExpired())
.max(Comparator.comparing(Reservation::getCreatedAt)).orElse(null);
// createdAt 내림차순으로 조회되므로 첫 건이 가장 최근 활성 예약이다
final Reservation latest = reservationRepository.findCurrentlyActiveByUser(user).stream().findFirst()
.orElse(null);

if (latest == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ public RoomActiveReservationsResDto execute() {
final User user = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND));

final var reservations = reservationRepository.findActiveReservationsByRoomNumber(user.getRoomNumber()).stream()
.filter(r -> !r.isExpired())
final var reservations = reservationRepository.findCurrentlyActiveByRoomNumber(user.getRoomNumber()).stream()
.map(r -> new ReservationResDto(r.getId(),
r.getUser().getId(),
r.getUser().getName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@
@RequiredArgsConstructor
public class ReservationCreationSupport {

private static final List<ReservationStatus> ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
ReservationStatus.RUNNING);

private final ReservationRepository reservationRepository;
private final MachineRepository machineRepository;
private final WashingBanRepository washingBanRepository;
Expand Down Expand Up @@ -91,61 +88,49 @@ public Machine lockMachine(final Long machineId) {
* 락을 획득한 기기
*/
public void validateMachineAndReservations(final User user, final Machine machine) {
final var activeMachineReservations = reservationRepository.findByMachineAndStatusIn(machine, ACTIVE_STATUSES);
final var machineReservations = reservationRepository.findCurrentlyActiveByMachine(machine);

// 기기 가용성 검증
if (machine.getAvailability() != MachineAvailability.AVAILABLE
&& !canReuseStaleReservedSlot(machine, activeMachineReservations)) {
&& !canReuseStaleReservedSlot(machine, machineReservations)) {
throw new ExpectedException(String.format("해당 기기를 사용할 수 없습니다. 기기: %s", machine.getName()),
HttpStatus.BAD_REQUEST);
}

// 기기 단위 중복 예약 검증 (가용성 플래그 드리프트에 대한 방어 심화)
if (hasCurrentActiveReservation(activeMachineReservations)) {
if (!machineReservations.isEmpty()) {
throw new ExpectedException(String.format("해당 기기에 이미 진행 중인 예약이 있습니다. 기기: %s", machine.getName()),
HttpStatus.CONFLICT);
}

// 개인 중복 예약 검증 (1인 1예약)
final var userActiveReservations = reservationRepository.findByUserAndStatusIn(user, ACTIVE_STATUSES);
if (hasCurrentActiveReservation(userActiveReservations)) {
if (!reservationRepository.findCurrentlyActiveByUser(user).isEmpty()) {
throw new ExpectedException("이미 활성 예약이 존재합니다. 1인 1예약만 가능합니다.", HttpStatus.BAD_REQUEST);
}

// 동일 호실의 동일 유형 기기 중복 예약 검증
final boolean hasDuplicateTypeReservation = reservationRepository
.findActiveReservationsByRoomNumber(user.getRoomNumber()).stream()
.filter(reservation -> reservation.getMachine().getType() == machine.getType())
.anyMatch(reservation -> reservation.isActive() && !reservation.isExpired());
.findCurrentlyActiveByRoomNumber(user.getRoomNumber()).stream()
.anyMatch(reservation -> reservation.getMachine().getType() == machine.getType());
if (hasDuplicateTypeReservation) {
throw new ExpectedException(String.format("해당 호실에 이미 %s 예약이 존재합니다. 동일 유형의 기기는 동시에 두 개 이상 예약할 수 없습니다.",
machine.getType().getDescription()), HttpStatus.BAD_REQUEST);
}
}

/**
* 타임아웃이 지나지 않은 진짜 활성 예약이 있는지 판정합니다.
*
* @param reservations
* 판정 대상 예약 목록
* @return 만료되지 않은 활성 예약 존재 여부
*/
private boolean hasCurrentActiveReservation(final List<Reservation> reservations) {
return reservations.stream().anyMatch(reservation -> reservation.isActive() && !reservation.isExpired());
}

/**
* 만료된 예약만 남아 RESERVED로 굳어버린 기기를 재사용할 수 있는지 판정합니다.
*
* @param machine
* 락을 획득한 기기
* @param activeReservations
* 해당 기기의 활성 상태 예약 목록
* @param currentlyActiveReservations
* 해당 기기의 만료되지 않은 활성 예약 목록
* @return 재사용 가능 여부
*/
private boolean canReuseStaleReservedSlot(final Machine machine, final List<Reservation> activeReservations) {
private boolean canReuseStaleReservedSlot(final Machine machine,
final List<Reservation> currentlyActiveReservations) {
return machine.getStatus() == MachineStatus.NORMAL && machine.getAvailability() == MachineAvailability.RESERVED
&& !hasCurrentActiveReservation(activeReservations);
&& currentlyActiveReservations.isEmpty();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public static Optional<Reservation> selectPrimary(List<Reservation> activeReserv
if (activeReservations == null || activeReservations.isEmpty()) {
return Optional.empty();
}
return activeReservations.stream().filter(Reservation::isRunning).findFirst().or(() -> activeReservations
.stream().filter(reservation -> reservation.isReserved() && !reservation.isExpired()).findFirst());
// RUNNING이 하나도 없을 때만 두 번째 분기에 도달하므로, 여기서 통과하는 것은 만료되지 않은 RESERVED뿐이다
return activeReservations.stream().filter(Reservation::isRunning).findFirst()
.or(() -> activeReservations.stream().filter(Reservation::isCurrentlyActive).findFirst());
}
}
Loading