Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
536d59f
add(notification): 관리자 패널티 부과 알림 추가
d1ng1724 Sep 2, 2026
e20e323
add(reservation): 관리자 세탁 패널티 부과 기능 추가
d1ng1724 Sep 2, 2026
224735e
test(reservation): 관리자 세탁 패널티 부과 테스트 추가
d1ng1724 Sep 2, 2026
07ce2f0
docs(reservation): 관리자 세탁 패널티 부과 스펙 추가
d1ng1724 Sep 2, 2026
94e428d
fix(notification): 알림 타입 컬럼 길이 부족 수정
d1ng1724 Sep 2, 2026
25d285f
fix(reservation): 패널티 부과 성공 판정 오류 수정
d1ng1724 Sep 2, 2026
f1ee7c6
docs(reservation): 패널티 부과 스펙 리뷰 반영
d1ng1724 Sep 2, 2026
249a304
Merge pull request #121 from team-washer/add/admin-washing-penalty
d1ng1724 Sep 2, 2026
3ac623b
add(machine): 점유 상태에서만 해제하는 releaseIfHeld 추가
d1ng1724 Sep 2, 2026
44a26f1
fix(reservation): 예약 해제 시 고장 기기가 복구되는 문제 수정
d1ng1724 Sep 2, 2026
2a45cf8
fix(user): 탈퇴 시 고장 기기가 복구되는 문제 수정
d1ng1724 Sep 2, 2026
f7f5bba
fix(machine): releaseIfHeld에 고장 상태 검사 추가
d1ng1724 Sep 3, 2026
926b671
Merge pull request #122 from team-washer/fix/machine-release-if-held
d1ng1724 Sep 3, 2026
f5cccdd
add(smartthings): 주간 무세제 통세척 자동 실행
exijn Sep 3, 2026
b8d4c22
fix(smartthings): 통세척 테스트 시간대 일치
exijn Sep 3, 2026
a692017
delete: 미사용 예약 리포지토리 메서드 제거
d1ng1724 Sep 3, 2026
dfe939b
fix: PR #123에서 사용 중인 countActiveReservationsByMachine 복원
d1ng1724 Sep 3, 2026
9c90b37
Merge pull request #123 from team-washer/feat/weekly-washer-tub-clean
exijn Sep 3, 2026
a3e78b8
Merge remote-tracking branch 'origin/develop' into fix/remove-unused-…
d1ng1724 Sep 3, 2026
3e92029
Merge pull request #124 from team-washer/fix/remove-unused-reservatio…
d1ng1724 Sep 3, 2026
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
401 changes: 401 additions & 0 deletions docs/spec-admin-penalty.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ public void markAsAvailable() {
this.availability = MachineAvailability.AVAILABLE;
}

/**
* 예약 또는 사용 중으로 점유되어 있던 경우에만 기기를 사용 가능 상태로 해제합니다.
*
* <p>
* 고장난 기기는 해제하지 않고 {@code UNAVAILABLE}로 되돌립니다. {@code status}가
* {@code MALFUNCTION}이면 {@code availability}도 {@code UNAVAILABLE}이어야 한다는 불변식을
* 예약 해제 경로에서 보장하기 위함입니다. 고장 처리 시점에 이미 진행 중이던 예약이 스케줄러에 의해 {@code IN_USE}로 전환되는
* 등, 다른 경로에서 어긋난 상태로 들어오더라도 이 지점에서 불변식이 복원됩니다.
*
* <p>
* 고장이 아닌데 {@code UNAVAILABLE}로 차단된 기기는 관리자가 의도적으로 내린 상태이므로 그대로 유지합니다.
*/
public void releaseIfHeld() {
if (this.status == MachineStatus.MALFUNCTION) {
this.availability = MachineAvailability.UNAVAILABLE;
return;
}
if (this.availability == MachineAvailability.RESERVED || this.availability == MachineAvailability.IN_USE) {
this.availability = MachineAvailability.AVAILABLE;
}
}

/**
* 기기 사용 중 상태로 변경합니다.
*/
Expand All @@ -120,6 +142,25 @@ public void markAsReserved() {
this.availability = MachineAvailability.RESERVED;
}

/**
* 기기를 통세척 중 상태로 변경합니다.
*/
public void markAsCleaning() {
this.availability = MachineAvailability.CLEANING;
}

/**
* 통세척 중인 기기를 정상 상태에 맞게 해제합니다.
*/
public void finishCleaning() {
if (this.availability != MachineAvailability.CLEANING) {
return;
}
this.availability = this.status == MachineStatus.NORMAL
? MachineAvailability.AVAILABLE
: MachineAvailability.UNAVAILABLE;
}

/**
* 기기 사용 불가 상태로 변경합니다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
@Getter
@AllArgsConstructor
public enum MachineAvailability {
AVAILABLE("사용 가능"), IN_USE("사용 중"), RESERVED("예약됨"), UNAVAILABLE("사용 불가");
AVAILABLE("사용 가능"), IN_USE("사용 중"), RESERVED("예약됨"), CLEANING("통세척 중"), UNAVAILABLE("사용 불가");

private final String description;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ public interface MachineRepository extends JpaRepository<Machine, Long>, Machine

List<Machine> findByType(MachineType type);

List<Machine> findByTypeAndStatusAndAvailability(MachineType type,
MachineStatus status,
MachineAvailability availability);

List<Machine> findByTypeAndAvailability(MachineType type, MachineAvailability availability);

List<Machine> findByFloor(Integer floor);

List<Machine> findByStatus(MachineStatus status);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ private MachineStatusResDto mapToStatusDto(Machine machine,

/**
* 예약 정보와 실제 기기 작동 상태를 기반으로 가용성을 동적으로 계산한다. 예약 상태를 유일한 source of truth로 사용하되,
* 예약이 없어도 SmartThings에서 실제 작동 중(무단 사용)이면 IN_USE로 표시해 중복 예약을 차단한다.
* 예약이 없어도 SmartThings에서 실제 작동 중(무단 사용)이면 IN_USE로 표시해 중복 예약을 차단한다. 사용 불가와 통세척 중
* 상태는 DB에 저장된 관리 상태를 우선한다.
*
* <p>
* 완료 여부를 이 API에서 따로 예측하지 않는다. 완료 확정은 라이프사이클 스케줄러가 디바운스와 가드를 거쳐 DB에 반영하며, 목록은 그
Expand All @@ -115,8 +116,9 @@ private MachineStatusResDto mapToStatusDto(Machine machine,
private MachineAvailability computeAvailability(Machine machine,
Reservation reservation,
SmartThingsDeviceStatusResDto deviceStatus) {
if (machine.getAvailability() == MachineAvailability.UNAVAILABLE) {
return MachineAvailability.UNAVAILABLE;
if (machine.getAvailability() == MachineAvailability.UNAVAILABLE
|| machine.getAvailability() == MachineAvailability.CLEANING) {
return machine.getAvailability();
}
if (reservation == null) {
return isOperating(machine, deviceStatus) ? MachineAvailability.IN_USE : MachineAvailability.AVAILABLE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public class Notification extends BaseEntity {

@NotNull(message = "알림 유형은 필수입니다")
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
@Column(name = "type", nullable = false, length = 50)
private NotificationType type;

@ManyToOne(fetch = FetchType.LAZY)
Expand Down Expand Up @@ -231,6 +231,22 @@ public static Notification createBlockExtensionNotification(User user, LocalDate
.isRead(false).build();
}

/**
* 관리자 패널티 부과 알림을 생성합니다.
*
* @param user
* 알림 수신 사용자
* @param reason
* 패널티 부과 사유
* @return 생성된 관리자 패널티 알림
*/
public static Notification createAdminPenaltyNotification(User user, String reason) {
String message = NotificationType.ADMIN_PENALTY_BLOCKED.getMessageTemplate().replace("{reason}", reason);

return Notification.builder().user(user).type(NotificationType.ADMIN_PENALTY_BLOCKED).message(message)
.isRead(false).build();
}

/**
* 알림을 읽음 상태로 변경합니다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ public enum NotificationType {
"예약 차단 연장 알림",
"관리자에 의해 예약 차단 기간이 연장되었습니다. {expiryAt}까지 해당 호실의 예약이 제한됩니다."), FORCE_STOPPED(
"강제 정지 알림",
"관리자에 의해 {machineName}의 {action} 정지되어 예약이 패널티 없이 취소되었습니다.");
"관리자에 의해 {machineName}의 {action} 정지되어 예약이 패널티 없이 취소되었습니다."), ADMIN_PENALTY_BLOCKED(
"예약 차단 알림",
"관리자에 의해 해당 호실의 예약이 48시간 동안 제한됩니다.\n\n사유: {reason}");

private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter EXPIRY_FORMATTER = DateTimeFormatter.ofPattern("MM월 dd일 HH시 mm분");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ public void sendBlockExtension(User user, LocalDateTime newExpiryAt) {
persistAndSend(user, notification, "예약 차단 알림");
}

/**
* 관리자 패널티 부과 알림을 전송한다.
*/
@Transactional
public void sendAdminPenalty(User user, String reason) {
var notification = Notification.createAdminPenaltyNotification(user, reason);
persistAndSend(user, notification, "예약 차단 알림");
}

private void persistAndSend(final User user, final Notification notification, final String fcmTitle) {
notificationRepository.save(notification);
enforceNotificationLimit(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import team.themoment.sdk.response.CommonApiResponse;
import team.washer.server.v2.domain.machine.enums.MachineType;
import team.washer.server.v2.domain.reservation.dto.request.AdminCreateReservationReqDto;
import team.washer.server.v2.domain.reservation.dto.request.ApplyUserPenaltyReqDto;
import team.washer.server.v2.domain.reservation.dto.request.ExtendBlockReqDto;
import team.washer.server.v2.domain.reservation.dto.response.AdminCancellationResDto;
import team.washer.server.v2.domain.reservation.dto.response.AdminMachineHistoryResDto;
Expand All @@ -32,6 +33,7 @@
public class AdminReservationController {

private final QueryPenaltyStatusService queryPenaltyStatusService;
private final ApplyUserPenaltyService applyUserPenaltyService;
private final ClearUserPenaltyService clearUserPenaltyService;
private final ExtendCancellationBlockService extendCancellationBlockService;
private final QueryAllReservationsService queryAllReservationsService;
Expand All @@ -53,6 +55,15 @@ public PenaltyStatusResDto getUserPenaltyStatus(
return queryPenaltyStatusService.execute(userId);
}

@PostMapping("/users/{userId}/penalty")
@Operation(summary = "사용자 세탁 패널티 부과", description = "특정 사용자의 호실에 48시간 예약 차단을 부과합니다. 5분 패널티 5회 누적과 동일한 제재이며, 관리자와 기숙사자치위원회가 사용할 수 있습니다. 이미 차단 중인 호실은 차단 기간이 48시간으로 갱신됩니다.")
public CommonApiResponse applyUserPenalty(@Parameter(description = "사용자 ID") @PathVariable @NotNull Long userId,
@Valid @RequestBody ApplyUserPenaltyReqDto reqDto) {

applyUserPenaltyService.execute(userId, reqDto.reason());
return CommonApiResponse.success("세탁 패널티가 부과되었습니다.");
}

@DeleteMapping("/users/{userId}/penalty")
@Operation(summary = "사용자 패널티 해제", description = "특정 사용자의 패널티를 해제합니다. ADMIN 권한이 필요합니다.")
public CommonApiResponse clearUserPenalty(@Parameter(description = "사용자 ID") @PathVariable @NotNull Long userId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package team.washer.server.v2.domain.reservation.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Schema(description = "세탁 패널티 부과 요청 DTO")
public record ApplyUserPenaltyReqDto(
@NotBlank(message = "부과 사유는 필수입니다") @Size(max = 200, message = "부과 사유는 200자를 초과할 수 없습니다") @Schema(description = "패널티 부과 사유", example = "세탁물 장기 방치로 기기 점유") String reason) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package team.washer.server.v2.domain.reservation.repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -72,10 +71,6 @@ default Optional<Reservation> findActiveReservationByMachineId(Long machineId) {
List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)));
}

@Query("SELECT r FROM Reservation r WHERE r.status = :status AND r.startTime < :threshold")
List<Reservation> findExpiredReservedReservations(@Param("status") ReservationStatus status,
@Param("threshold") LocalDateTime threshold);

@Query("SELECT COUNT(r) FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses")
long countActiveReservationsByMachine(@Param("machine") Machine machine,
@Param("statuses") List<ReservationStatus> statuses);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package team.washer.server.v2.domain.reservation.service;

public interface ApplyUserPenaltyService {

/**
* 대상 사용자의 호실에 48시간 세탁 패널티를 부과합니다.
*
* @param userId
* 패널티 부과 대상 사용자 ID
* @param reason
* 부과 사유
*/
void execute(Long userId, String reason);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public AdminCancellationResDto execute(Long reservationId) {
}
reservation.cancel();
final var machine = reservation.getMachine();
machine.markAsAvailable();
machine.releaseIfHeld();
final var savedReservation = reservationRepository.save(reservation);
return new AdminCancellationResDto(savedReservation.getId(),
savedReservation.getUser().getName(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package team.washer.server.v2.domain.reservation.service.impl;

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

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import team.themoment.sdk.exception.ExpectedException;
import team.washer.server.v2.domain.notification.support.ReservationNotificationSupport;
import team.washer.server.v2.domain.reservation.service.ApplyUserPenaltyService;
import team.washer.server.v2.domain.reservation.util.PenaltyRedisUtil;
import team.washer.server.v2.domain.user.entity.User;
import team.washer.server.v2.domain.user.repository.UserRepository;
import team.washer.server.v2.global.security.provider.CurrentUserProvider;

@Slf4j
@Service
@RequiredArgsConstructor
public class ApplyUserPenaltyServiceImpl implements ApplyUserPenaltyService {

private final UserRepository userRepository;
private final PenaltyRedisUtil penaltyRedisUtil;
private final ReservationNotificationSupport reservationNotificationSupport;
private final CurrentUserProvider currentUserProvider;

@Override
@Transactional
public void execute(final Long userId, final String reason) {
final var actorId = currentUserProvider.getCurrentUserId();

// 권한 검사는 SecurityConfig가 담당한다(DORMITORY_COUNCIL, ADMIN 허용).
// 자치위·관리자 구분은 감사 로그에만 남기므로 actor는 조회만 한다.
final User actor = userRepository.findById(actorId)
.orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));

if (actorId.equals(userId)) {
throw new ExpectedException("자신에게는 패널티를 부과할 수 없습니다.", HttpStatus.BAD_REQUEST);
}

final User target = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));

final String roomNumber = target.getRoomNumber();
if (roomNumber == null) {
throw new ExpectedException("호실 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND);
}

// 관리자에게 거짓 성공 응답이 나가면 제재가 집행되지 않은 채 종료되므로 저장 실패를 예외로 받는다.
// isBlocked로 판정하면 이미 차단 중인 호실에서 TTL 갱신 실패를 성공으로 오인한다.
try {
penaltyRedisUtil.applyBlockOrThrow(roomNumber);
} catch (Exception e) {
log.error("failed to apply admin penalty roomNumber={} targetId={} actorId={}",
roomNumber,
userId,
actorId,
e);
throw new ExpectedException("패널티 부과에 실패했습니다. 잠시 후 다시 시도해 주세요.", HttpStatus.INTERNAL_SERVER_ERROR);
}

log.info("admin penalty applied roomNumber={} targetId={} actorId={} actorRole={} reason={}",
roomNumber,
userId,
actorId,
actor.getRole(),
reason);

reservationNotificationSupport.sendAdminPenalty(target, reason);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public CancellationResDto execute(final Long reservationId) {

final var machine = reservation.getMachine();
reservation.cancel();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
log.info("Cancelled reservation reservationId={} userId={}", reservationId, userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public OverdueResult processOverdue(Long reservationId, SmartThingsDeviceStatusR
if (startDecision == StartDecision.UNKNOWN) {
if (canCancelUnknownReservation(reservation)) {
reservation.cancel();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
log.warn("reservation timeout cancelled without penalty due to unknown start state reservationId={}",
Expand All @@ -109,7 +109,7 @@ public OverdueResult processOverdue(Long reservationId, SmartThingsDeviceStatusR
}

reservation.cancel();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ private void processInterruption(Reservation reservation, Machine machine) {

reservation.cancel();
reservation.clearInterruptionCount();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);

Expand Down Expand Up @@ -214,7 +214,7 @@ private void processPaused(Reservation reservation, Machine machine) {

reservation.cancel();
reservation.clearPausedAt();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);

Expand Down Expand Up @@ -276,7 +276,7 @@ private void completeReservation(Reservation reservation,
reservation.clearCompletionCount();
reservation.clearInterruptionCount();
reservation.clearPausedAt();
machine.markAsAvailable();
machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);

Expand Down
Loading
Loading