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 @@ -32,7 +32,7 @@ public class QueryAdminDashboardServiceImpl implements QueryAdminDashboardServic
public AdminDashboardResDto execute() {
log.info("Querying admin dashboard statistics");

var activeReservations = reservationRepository.countActiveReservations();
var activeReservations = reservationRepository.countCurrentlyActive();
var pendingReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING);
var processingReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS);
var completedReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public DeleteMachineResDto execute(Long machineId) {
final var machine = machineRepository.findById(machineId)
.orElseThrow(() -> new ExpectedException("기기를 찾을 수 없습니다", HttpStatus.NOT_FOUND));

if (reservationRepository.findActiveReservationByMachineId(machineId).isPresent()) {
if (reservationRepository.findCurrentlyActiveReservationByMachineId(machineId).isPresent()) {
throw new ExpectedException("활성 예약이 존재하는 기기는 삭제할 수 없습니다", HttpStatus.BAD_REQUEST);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public List<MachineStatusResDto> execute(Long userId, boolean sorted) {
var deviceStatusMap = deviceStatusQuerySupport.queryAllDevicesStatus(deviceIds);

var results = machines.stream().map(machine -> {
var reservation = reservationRepository.findActiveReservationByMachineId(machine.getId()).orElse(null);
var reservation = reservationRepository.findCurrentlyActiveReservationByMachineId(machine.getId())
.orElse(null);
return mapToStatusDto(machine, deviceStatusMap.get(machine.getDeviceId()), reservation);
}).toList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ public MachineStatusUpdateResDto execute(Long machineId, MachineStatus status) {
} else if (status == MachineStatus.NORMAL) {
machine.markAsNormal();

// 복구 후 실제 예약 상태에 맞게 availability 재동기화
findActiveReservation(machine).ifPresent(reservation -> {
// 복구 후 실제 예약 상태에 맞게 availability 재동기화. 만료된 RESERVED 예약은 활성으로 보지 않으므로
// markAsNormal()이 설정한 AVAILABLE이 그대로 유지된다
findCurrentlyActiveReservation(machine).ifPresent(reservation -> {
switch (reservation.getStatus()) {
case RESERVED -> machine.markAsReserved();
case RUNNING -> machine.markAsInUse();
Expand All @@ -53,7 +54,7 @@ public MachineStatusUpdateResDto execute(Long machineId, MachineStatus status) {
savedMachine.getAvailability());
}

private Optional<Reservation> findActiveReservation(Machine machine) {
return reservationRepository.findActiveReservationByMachineId(machine.getId());
private Optional<Reservation> findCurrentlyActiveReservation(Machine machine) {
return reservationRepository.findCurrentlyActiveReservationByMachineId(machine.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,21 @@ default List<Reservation> findAllActiveReservations() {
return findByStatusIn(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING));
}

@Query("SELECT COUNT(r) FROM Reservation r WHERE r.status IN :statuses")
long countAllActiveReservations(@Param("statuses") List<ReservationStatus> statuses);

default long countActiveReservations() {
return countAllActiveReservations(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING));
}

@Query("SELECT r FROM Reservation r WHERE r.machine.id = :machineId AND r.status IN :statuses ORDER BY r.createdAt DESC")
List<Reservation> findFirstActiveReservationByMachineId(@Param("machineId") Long machineId,
@Param("statuses") List<ReservationStatus> statuses);

/**
* 기기의 대표 활성 예약을 조회한다. 선택 규칙은 {@link ActiveReservationSelector}가 정의한다.
* 기기의 대표 활성 예약을 조회한다. 만료 예약 제외는
* {@link ReservationRepositoryCustom#findCurrentlyActiveByMachineId(Long)}가 쿼리
* 단계에서 처리하고, 남은 후보 중 대표를 고르는 규칙은 {@link ActiveReservationSelector}가 정의한다.
*
* <p>
* 타임아웃이 지난 RESERVED 예약만 남은 기기는 {@link Optional#empty()}가 된다. 스케줄러가 아직 정리하지 못한
* 만료 예약이 기기를 점유한 것처럼 보이게 하지 않기 위함이다.
*/
default Optional<Reservation> findActiveReservationByMachineId(Long machineId) {
return ActiveReservationSelector.selectPrimary(findFirstActiveReservationByMachineId(machineId,
List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)));
default Optional<Reservation> findCurrentlyActiveReservationByMachineId(Long machineId) {
return ActiveReservationSelector.selectPrimary(findCurrentlyActiveByMachineId(machineId));
}

@Query("SELECT COUNT(r) FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses")
Expand All @@ -76,6 +74,4 @@ default List<Reservation> findAllRunningReservations() {

@Query("SELECT DISTINCT r.machine.id FROM Reservation r WHERE r.status IN :statuses")
List<Long> findMachineIdsByStatusIn(@Param("statuses") List<ReservationStatus> statuses);

boolean existsByUserAndStatusIn(User user, List<ReservationStatus> statuses);
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ Page<Reservation> findAllWithFilters(String userName,
*/
List<Reservation> findCurrentlyActiveByRoomNumber(String roomNumber);

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

/**
* 현재 활성 예약이 걸려 있는 기기 ID 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약만 남은 기기는 포함되지 않습니다.
*
* @return 만료되지 않은 활성 예약이 있는 기기 ID 목록
*/
List<Long> findCurrentlyActiveMachineIds();

/**
* 현재 활성 예약 수를 반환합니다. 타임아웃이 지난 RESERVED 예약은 집계에서 제외됩니다.
*
* @return 만료되지 않은 활성 예약 수
*/
long countCurrentlyActive();

/**
* 사용자에게 현재 활성 예약이 있는지 반환합니다. 타임아웃이 지난 RESERVED 예약만 남아 있으면 거짓입니다.
*
* @param user
* 조회 대상 사용자
* @return 만료되지 않은 활성 예약 존재 여부
*/
boolean existsCurrentlyActiveByUser(User user);

/**
* 기기별 예약 히스토리 조회
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,33 @@ public List<Reservation> findCurrentlyActiveByRoomNumber(String roomNumber) {
.orderBy(reservation.createdAt.desc()).fetch();
}

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

@Override
public List<Long> findCurrentlyActiveMachineIds() {
return jpaQueryFactory.select(reservation.machine.id).distinct().from(reservation).where(currentlyActive())
.fetch();
}

@Override
public long countCurrentlyActive() {
final var total = jpaQueryFactory.select(reservation.count()).from(reservation).where(currentlyActive())
.fetchOne();

return total != null ? total : 0L;
}

@Override
public boolean existsCurrentlyActiveByUser(User targetUser) {
return jpaQueryFactory.selectOne().from(reservation).where(reservation.user.eq(targetUser), currentlyActive())
.fetchFirst() != null;
}

/**
* 만료되지 않은 활성 예약 조건을 반환합니다. {@link Reservation#isCurrentlyActive()}와 동일한 규칙을 쿼리
* 조건으로 표현한 것으로, 전체를 로드한 뒤 메모리에서 거르지 않도록 합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package team.washer.server.v2.domain.smartthings.service.impl;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

Expand All @@ -12,7 +11,6 @@
import lombok.extern.slf4j.Slf4j;
import team.washer.server.v2.domain.machine.entity.Machine;
import team.washer.server.v2.domain.machine.repository.MachineRepository;
import team.washer.server.v2.domain.reservation.enums.ReservationStatus;
import team.washer.server.v2.domain.reservation.repository.ReservationRepository;
import team.washer.server.v2.domain.smartthings.exception.SmartThingsPermissionException;
import team.washer.server.v2.domain.smartthings.service.ShutdownIdleMachinesService;
Expand All @@ -33,17 +31,15 @@ public class ShutdownIdleMachinesServiceImpl implements ShutdownIdleMachinesServ
@Autowired(required = false)
private DiscordErrorNotificationService discordErrorNotificationService;

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

@Override
public void execute() {
var machines = machineRepository.findAll();
if (machines.isEmpty()) {
return;
}

var activeMachineIds = Set.copyOf(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES));
// 만료 예약만 남은 기기는 실제로 아무도 쓰지 않으므로 유휴 전원 차단 대상에 포함한다
var activeMachineIds = Set.copyOf(reservationRepository.findCurrentlyActiveMachineIds());

var idleCandidates = machines.stream().filter(machine -> !activeMachineIds.contains(machine.getId())).toList();
var skippedActiveCount = machines.size() - idleCandidates.size();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
package team.washer.server.v2.domain.user.service.impl;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import team.themoment.sdk.exception.ExpectedException;
import team.washer.server.v2.domain.reservation.enums.ReservationStatus;
import team.washer.server.v2.domain.reservation.repository.ReservationRepository;
import team.washer.server.v2.domain.user.repository.UserRepository;
import team.washer.server.v2.domain.user.service.DeleteUserService;
Expand All @@ -29,9 +26,8 @@ public void execute(Long userId) {
final var user = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND));

// 활성 예약이 있는지 확인
final var activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING);
final boolean hasActiveReservations = reservationRepository.existsByUserAndStatusIn(user, activeStatuses);
// 만료되지 않은 활성 예약이 있는지 확인. 만료 예약만 남은 사용자는 본인 탈퇴와 동일하게 삭제할 수 있어야 한다
final boolean hasActiveReservations = reservationRepository.existsCurrentlyActiveByUser(user);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MEDIUM · DATA_INTEGRITY

만료 예약을 정리하지 않고 사용자를 삭제합니다

existsCurrentlyActiveByUser()는 스케줄러가 아직 정리하지 않은 만료 RESERVED 행이 있어도 false를 반환하므로 서비스가 곧바로 userRepository.delete(user)를 실행합니다. 그러나 해당 예약의 만료 처리와 연결된 기기의 availability 복구는 수행하지 않습니다.

예약 FK가 사용자 삭제를 제한하면 탈퇴가 무결성 제약 위반으로 실패하고, cascade로 예약이 삭제되면 스케줄러가 더 이상 그 예약을 발견할 수 없어 기기가 예약 당시의 비가용 상태로 남을 수 있습니다. 사용자를 삭제하기 전에 만료 예약을 정상적인 만료 경로로 처리하여 기기를 해제해야 합니다.


if (hasActiveReservations) {
throw new ExpectedException("활성 예약이 있는 사용자는 삭제할 수 없습니다", HttpStatus.BAD_REQUEST);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class ExecuteTest {
@DisplayName("활성 예약, 고장 신고, 기기, 세탁정지 학생 통계를 성공적으로 조회한다")
void execute_ShouldReturnDashboardStatistics_WhenDataExists() {
// Given
when(reservationRepository.countActiveReservations()).thenReturn(5L);
when(reservationRepository.countCurrentlyActive()).thenReturn(5L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING)).thenReturn(3L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS)).thenReturn(2L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED)).thenReturn(10L);
Expand All @@ -72,7 +72,7 @@ void execute_ShouldReturnDashboardStatistics_WhenDataExists() {
@DisplayName("데이터가 없으면 모든 통계가 0으로 반환된다")
void execute_ShouldReturnZeroStatistics_WhenNoDataExists() {
// Given
when(reservationRepository.countActiveReservations()).thenReturn(0L);
when(reservationRepository.countCurrentlyActive()).thenReturn(0L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING)).thenReturn(0L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS)).thenReturn(0L);
when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED)).thenReturn(0L);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ void it_deletes_machine_and_returns_info() {
var machineId = 1L;
var machine = createMachine();
given(machineRepository.findById(machineId)).willReturn(Optional.of(machine));
given(reservationRepository.findActiveReservationByMachineId(machineId)).willReturn(Optional.empty());
given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId))
.willReturn(Optional.empty());

// When
var result = deleteMachineService.execute(machineId);
Expand All @@ -85,7 +86,7 @@ void it_throws_bad_request_exception() {
var machineId = 1L;
var machine = createMachine();
given(machineRepository.findById(machineId)).willReturn(Optional.of(machine));
given(reservationRepository.findActiveReservationByMachineId(machineId))
given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId))
.willReturn(Optional.of(activeReservation));

// When & Then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ void execute_ShouldReturnSortedMachinesStatus_WhenSortedIsTrue() {
when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1", "device-2")))
.thenReturn(Map.of("device-1", deviceStatus, "device-2", deviceStatus));

when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.empty());
when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())).thenReturn(Optional.empty());

// When
var result = queryAllMachinesStatusService.execute(USER_ID, true);
Expand All @@ -122,7 +122,7 @@ void execute_ShouldReturnUnsortedMachinesStatus_WhenSortedIsFalse() {

when(machineRepository.findAll()).thenReturn(List.of(machine1));
when(deviceStatusQuerySupport.queryAllDevicesStatus(any())).thenReturn(Map.of());
when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.empty());
when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())).thenReturn(Optional.empty());

// When
var result = queryAllMachinesStatusService.execute(USER_ID, false);
Expand Down Expand Up @@ -174,7 +174,8 @@ void execute_ShouldUseDryerCompletionTime_WhenDryerStatusContainsWasherAndDryerC
when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine));
when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
.thenReturn(Map.of("device-1", deviceStatus));
when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.of(reservation));
when(reservationRepository.findCurrentlyActiveReservationByMachineId(any()))
.thenReturn(Optional.of(reservation));
when(reservation.getStatus()).thenReturn(ReservationStatus.RUNNING);
when(reservation.getUser()).thenReturn(user);

Expand Down Expand Up @@ -211,7 +212,8 @@ void execute_ShouldKeepReservationInfo_WhenDeviceReportsCompletionButReservation
when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine));
when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
.thenReturn(Map.of("device-1", deviceStatus));
when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.of(reservation));
when(reservationRepository.findCurrentlyActiveReservationByMachineId(any()))
.thenReturn(Optional.of(reservation));
when(reservation.getId()).thenReturn(10L);
when(reservation.getStatus()).thenReturn(ReservationStatus.RUNNING);
when(reservation.getUser()).thenReturn(user);
Expand Down Expand Up @@ -272,7 +274,7 @@ private void givenMachineWithReservation(Machine machine, Reservation reservatio
givenUserMocked();
when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine));
when(deviceStatusQuerySupport.queryAllDevicesStatus(any())).thenReturn(Map.of());
when(reservationRepository.findActiveReservationByMachineId(any()))
when(reservationRepository.findCurrentlyActiveReservationByMachineId(any()))
.thenReturn(Optional.ofNullable(reservationOrNull));
}

Expand Down
Loading
Loading