diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java index 8663bd51f..7f91a1eb7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java @@ -27,7 +27,8 @@ public interface AppLockerApi { /** 구역별 전체·선택 가능 사물함 수와 표시 상태. */ @Operation(summary = "사물함 구역 목록 조회", description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. " - + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.") + + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다. " + + "선택 가능 수는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 LOCKER_APPLY_NOT_OPEN이 난다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) ApiResponse getSections( @@ -40,7 +41,8 @@ ApiResponse getSections( description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. " + "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 " + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 " - + "선택할 수 있다.") + + "선택할 수 있다. 선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 " + + "LOCKER_APPLY_NOT_OPEN이 난다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) ApiResponse getSectionLockers( @@ -51,10 +53,12 @@ ApiResponse getSectionLockers( /** 고른 사물함을 신청하고 즉시 배정한다. */ @Operation(summary = "사물함 신청", - description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 운영 회차당 한 개만 신청할 수 있고 " - + "신청 후에는 취소하거나 변경할 수 없다. 같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.") + description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 모집 기간 안에서만 받는다. " + + "운영 회차당 한 개만 신청할 수 있고 신청 후에는 취소하거나 변경할 수 없다. " + + "같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_ALREADY_ASSIGNED"}) + @ApiErrorCode(type = LockerErrorCode.class, + codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_APPLY_NOT_OPEN", "LOCKER_ALREADY_ASSIGNED"}) ApiResponse apply(AppApiUser apiUser, LockerApplyRequest request); /** 내 사물함 신청 내역. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java index e8c1850a6..efcf6c636 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java @@ -19,6 +19,7 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_PERIOD_ALREADY_ENDED(ErrorStatus.CONFLICT, "종료된 회차는 수정할 수 없습니다."), LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."), LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."), + LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."), LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."); private final int status; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java index 3b3d74acd..f9d088226 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.temporal.ChronoUnit; import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -92,6 +93,24 @@ public LockerPeriodStatus calculateStatus(LocalDateTime now) { return LockerPeriodStatus.IN_PROGRESS; } + /** + * 지금이 신청을 받는 기간인지. 신청 시작 일시부터 마감 일시까지 양 끝을 포함한다. + *

+ * 사용 시작일과의 선후는 보지 않는다. 사용을 시작한 뒤 남은 자리를 추가 모집하려고 마감 일시를 늘리는 운영이 있어 + * 두 기간이 겹칠 수 있다. + *

+ * 사용이 끝난 회차는 받지 않는다. {@link #validateDates}가 마감일을 사용 종료일 안으로 묶어 두므로 정상 등록된 + * 회차에서는 이 조건이 걸리지 않지만, 관리자 API가 없던 시기에 DB로 직접 넣은 회차(V15·V20 주석)까지 막는다. + * + * @param now 판단 기준 시각. 마감 일시는 초까지만 저장되므로({@link #toApplyEndAt}) 초 단위로 끊어 견준다. + * 그러지 않으면 마감일 23:59:59.001부터는 나노초 때문에 마감으로 보인다 + */ + public boolean isApplyOpen(LocalDateTime now) { + LocalDateTime at = now.truncatedTo(ChronoUnit.SECONDS); + return !at.isBefore(applyStartAt) && !at.isAfter(applyEndAt) + && !isUsageEnded(at.toLocalDate()); + } + /** * 회차 범위(신청 시작일 ~ 사용 종료일)가 다른 회차와 겹치는지. 회차는 같은 시기에 둘이 돌 수 없다. */ @@ -124,7 +143,7 @@ private static String toName(int year, int semester) { * 운영이 있어 두 기간이 겹칠 수 있다. *

* 다만 신청 마감일은 사용 종료일을 넘을 수 없다. 넘기면 {@link #calculateStatus}가 종료로 보는 뒤에도 - * 신청 마감일이 남아 있고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다. + * {@link #isApplyOpen}이 신청을 받고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다. */ private static void validateDates( LocalDate applyStartDate, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java index f766c6703..796b66196 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java @@ -32,11 +32,13 @@ class LockerApplicationServiceImpl implements LockerApplicationService { @Override @Transactional public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { + LocalDateTime now = LocalDateTime.now(); LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); + validateApplyOpen(period, now); Locker locker = getSelectableLocker(period.getId(), command.lockerId()); LockerApplication application = lockerApplicationRepository.save( - LockerApplication.create(period.getId(), memberId, locker.getId(), LocalDateTime.now()) + LockerApplication.create(period.getId(), memberId, locker.getId(), now) ); outboxWriter.record(new LockerAssigned(memberId, period.getId())); return new LockerApplicationResult(application, locker, period); @@ -96,6 +98,19 @@ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); } + /** + * 모집 기간 밖에는 신청을 받지 않는다. 게시 여부만 보면 운영진이 게시를 내리기 전까지 마감 뒤에도 신청이 들어오고, + * 사물함은 선착순 즉시 배정이라 그 신청이 남의 자리를 차지한다. 행사 신청({@code EventApplicationServiceImpl})도 + * 같은 자리에서 모집 상태를 본다. + * + * @param now 신청 시각과 같은 값을 쓴다. 따로 잡으면 마감 판정을 통과한 신청이 마감 뒤 시각으로 기록될 수 있다 + */ + private void validateApplyOpen(LockerPeriod period, LocalDateTime now) { + if (!period.isApplyOpen(now)) { + throw new BusinessException(LockerErrorCode.LOCKER_APPLY_NOT_OPEN); + } + } + /** * 해당 운영 회차에 선택할 수 있는 사물함. 구역 조회와 같은 기준({@link Locker#isSelectable})으로 판정한다. *

diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java index 73f254cae..6c3010160 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java @@ -41,16 +41,17 @@ class LockerApplicationServiceImplTest { private static final Long MEMBER_ID = 100L; private static final Long LOCKER_ID = 11L; + /** 오늘 날짜와 무관하게 늘 모집 기간 안이도록 신청·사용 기간을 아주 먼 과거부터 먼 미래로 둔다. */ private static LockerPeriod period() { return LockerPeriod.of( PERIOD_ID, - 2026, - 2, - "2026-2학기", - LocalDateTime.of(2026, 8, 20, 10, 0), - LocalDateTime.of(2026, 8, 25, 18, 0), - LocalDate.of(2026, 9, 1), - LocalDate.of(2026, 12, 15), + 2000, + 1, + "2000-1학기", + LocalDateTime.of(2000, 1, 1, 0, 0), + LocalDateTime.of(2999, 12, 31, 23, 59, 59), + LocalDate.of(2000, 1, 2), + LocalDate.of(2999, 12, 31), true ); }