From 7e19d2ecc1b63818eeed234a3ce8d8a4066e5c1c Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 16 Oct 2026 19:23:17 +0900 Subject: [PATCH 01/34] =?UTF-8?q?fix:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=AA=A8=EC=A7=91=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/event/locker/AppLockerApi.java | 8 +++++--- .../domain/locker/domain/LockerErrorCode.java | 1 + .../event/domain/locker/domain/LockerPeriod.java | 10 ++++++++++ .../service/impl/LockerApplicationServiceImpl.java | 12 ++++++++++++ .../impl/LockerApplicationServiceImplTest.java | 14 ++++++++------ 5 files changed, 36 insertions(+), 9 deletions(-) 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..cf75b921b 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 @@ -51,10 +51,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..ad3472318 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 @@ -92,6 +92,16 @@ public LockerPeriodStatus calculateStatus(LocalDateTime now) { return LockerPeriodStatus.IN_PROGRESS; } + /** + * 지금이 신청을 받는 기간인지. 신청 시작 일시부터 마감 일시까지 양 끝을 포함한다. + *

+ * 사용 기간은 보지 않는다. 사용을 시작한 뒤 남은 자리를 추가 모집하려고 마감 일시를 늘리는 운영이 있어 + * 두 기간이 겹칠 수 있다. + */ + public boolean isApplyOpen(LocalDateTime now) { + return !now.isBefore(applyStartAt) && !now.isAfter(applyEndAt); + } + /** * 회차 범위(신청 시작일 ~ 사용 종료일)가 다른 회차와 겹치는지. 회차는 같은 시기에 둘이 돌 수 없다. */ 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..36b1bb13b 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 @@ -33,6 +33,7 @@ class LockerApplicationServiceImpl implements LockerApplicationService { @Transactional public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); + requireApplyOpen(period); Locker locker = getSelectableLocker(period.getId(), command.lockerId()); LockerApplication application = lockerApplicationRepository.save( @@ -96,6 +97,17 @@ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); } + /** + * 모집 기간 밖에는 신청을 받지 않는다. 게시 여부만 보면 운영진이 게시를 내리기 전까지 마감 뒤에도 신청이 들어오고, + * 사물함은 선착순 즉시 배정이라 그 신청이 남의 자리를 차지한다. 행사 신청({@code EventApplicationServiceImpl})도 + * 같은 자리에서 모집 상태를 본다. + */ + private void requireApplyOpen(LockerPeriod period) { + if (!period.isApplyOpen(LocalDateTime.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..cae515483 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,18 @@ class LockerApplicationServiceImplTest { private static final Long MEMBER_ID = 100L; private static final Long LOCKER_ID = 11L; + /** 신청이 모집 기간 안에서만 되므로 기간을 현재 시각 기준으로 잡는다. 고정 날짜로 두면 그 기간이 지난 뒤 테스트가 깨진다. */ private static LockerPeriod period() { + LocalDateTime now = LocalDateTime.now(); return LockerPeriod.of( PERIOD_ID, - 2026, + now.getYear(), 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), + "%d-2학기".formatted(now.getYear()), + now.minusDays(5), + now.plusDays(5), + now.toLocalDate().plusDays(12), + now.toLocalDate().plusMonths(4), true ); } From 92c0f763559c479dbc342d882a21b5fb73eb8839 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 19:23:07 +0900 Subject: [PATCH 02/34] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9D=B4=20?= =?UTF-8?q?=EB=81=9D=EB=82=9C=20=EC=82=AC=EB=AC=BC=ED=95=A8=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=EB=8A=94=20=EC=8B=A0=EC=B2=AD=EC=9D=84=20=EB=B0=9B?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/locker/domain/LockerPeriod.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 ad3472318..3d770579d 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 @@ -95,11 +95,15 @@ public LockerPeriodStatus calculateStatus(LocalDateTime now) { /** * 지금이 신청을 받는 기간인지. 신청 시작 일시부터 마감 일시까지 양 끝을 포함한다. *

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

+ * 사용이 끝난 회차는 받지 않는다. {@link #validateDates}가 마감일을 사용 종료일 안으로 묶어 두므로 정상 등록된 + * 회차에서는 이 조건이 걸리지 않지만, 관리자 API가 없던 시기에 DB로 직접 넣은 회차(V15·V20 주석)까지 막는다. */ public boolean isApplyOpen(LocalDateTime now) { - return !now.isBefore(applyStartAt) && !now.isAfter(applyEndAt); + return !now.isBefore(applyStartAt) && !now.isAfter(applyEndAt) + && !isUsageEnded(now.toLocalDate()); } /** @@ -134,7 +138,7 @@ private static String toName(int year, int semester) { * 운영이 있어 두 기간이 겹칠 수 있다. *

* 다만 신청 마감일은 사용 종료일을 넘을 수 없다. 넘기면 {@link #calculateStatus}가 종료로 보는 뒤에도 - * 신청 마감일이 남아 있고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다. + * {@link #isApplyOpen}이 신청을 받고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다. */ private static void validateDates( LocalDate applyStartDate, From 361105b9284061700ef3dd7ddab2fa3db4e30792 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 19:26:32 +0900 Subject: [PATCH 03/34] =?UTF-8?q?fix:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=A7=88=EA=B0=90=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EC=A4=80=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=EA=B0=81=EA=B3=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=EC=8B=9C=ED=82=A4=EA=B3=A0=20=EC=B4=88=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EB=B9=84=EA=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/locker/domain/LockerPeriod.java | 9 +++++++-- .../service/impl/LockerApplicationServiceImpl.java | 11 +++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) 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 3d770579d..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; @@ -100,10 +101,14 @@ public LockerPeriodStatus calculateStatus(LocalDateTime now) { *

* 사용이 끝난 회차는 받지 않는다. {@link #validateDates}가 마감일을 사용 종료일 안으로 묶어 두므로 정상 등록된 * 회차에서는 이 조건이 걸리지 않지만, 관리자 API가 없던 시기에 DB로 직접 넣은 회차(V15·V20 주석)까지 막는다. + * + * @param now 판단 기준 시각. 마감 일시는 초까지만 저장되므로({@link #toApplyEndAt}) 초 단위로 끊어 견준다. + * 그러지 않으면 마감일 23:59:59.001부터는 나노초 때문에 마감으로 보인다 */ public boolean isApplyOpen(LocalDateTime now) { - return !now.isBefore(applyStartAt) && !now.isAfter(applyEndAt) - && !isUsageEnded(now.toLocalDate()); + LocalDateTime at = now.truncatedTo(ChronoUnit.SECONDS); + return !at.isBefore(applyStartAt) && !at.isAfter(applyEndAt) + && !isUsageEnded(at.toLocalDate()); } /** 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 36b1bb13b..cf996440e 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,12 +32,13 @@ class LockerApplicationServiceImpl implements LockerApplicationService { @Override @Transactional public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { + LocalDateTime now = LocalDateTime.now(); LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); - requireApplyOpen(period); + requireApplyOpen(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); @@ -101,9 +102,11 @@ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { * 모집 기간 밖에는 신청을 받지 않는다. 게시 여부만 보면 운영진이 게시를 내리기 전까지 마감 뒤에도 신청이 들어오고, * 사물함은 선착순 즉시 배정이라 그 신청이 남의 자리를 차지한다. 행사 신청({@code EventApplicationServiceImpl})도 * 같은 자리에서 모집 상태를 본다. + * + * @param now 신청 시각과 같은 값을 쓴다. 따로 잡으면 마감 판정을 통과한 신청이 마감 뒤 시각으로 기록될 수 있다 */ - private void requireApplyOpen(LockerPeriod period) { - if (!period.isApplyOpen(LocalDateTime.now())) { + private void requireApplyOpen(LockerPeriod period, LocalDateTime now) { + if (!period.isApplyOpen(now)) { throw new BusinessException(LockerErrorCode.LOCKER_APPLY_NOT_OPEN); } } From a7e7080636513444206ccc68c5263c7345e0b505 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 19:29:33 +0900 Subject: [PATCH 04/34] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=EA=B8=B0=EA=B0=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=EC=9D=84=20validate~?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/service/impl/LockerApplicationServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 cf996440e..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 @@ -34,7 +34,7 @@ class LockerApplicationServiceImpl implements LockerApplicationService { public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { LocalDateTime now = LocalDateTime.now(); LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); - requireApplyOpen(period, now); + validateApplyOpen(period, now); Locker locker = getSelectableLocker(period.getId(), command.lockerId()); LockerApplication application = lockerApplicationRepository.save( @@ -105,7 +105,7 @@ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { * * @param now 신청 시각과 같은 값을 쓴다. 따로 잡으면 마감 판정을 통과한 신청이 마감 뒤 시각으로 기록될 수 있다 */ - private void requireApplyOpen(LockerPeriod period, LocalDateTime now) { + private void validateApplyOpen(LockerPeriod period, LocalDateTime now) { if (!period.isApplyOpen(now)) { throw new BusinessException(LockerErrorCode.LOCKER_APPLY_NOT_OPEN); } From 65e4f9aeb0f74bf421d8d2fdf3d1b47d6f91167b Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 19:31:54 +0900 Subject: [PATCH 05/34] =?UTF-8?q?test:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=20=ED=94=BD=EC=8A=A4=EC=B2=98=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EB=82=A0=EC=A7=9C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/LockerApplicationServiceImplTest.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 cae515483..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,18 +41,17 @@ class LockerApplicationServiceImplTest { private static final Long MEMBER_ID = 100L; private static final Long LOCKER_ID = 11L; - /** 신청이 모집 기간 안에서만 되므로 기간을 현재 시각 기준으로 잡는다. 고정 날짜로 두면 그 기간이 지난 뒤 테스트가 깨진다. */ + /** 오늘 날짜와 무관하게 늘 모집 기간 안이도록 신청·사용 기간을 아주 먼 과거부터 먼 미래로 둔다. */ private static LockerPeriod period() { - LocalDateTime now = LocalDateTime.now(); return LockerPeriod.of( PERIOD_ID, - now.getYear(), - 2, - "%d-2학기".formatted(now.getYear()), - now.minusDays(5), - now.plusDays(5), - now.toLocalDate().plusDays(12), - now.toLocalDate().plusMonths(4), + 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 ); } From b064feda9375ab986ad880e5b4c087636012e04c Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 19:34:50 +0900 Subject: [PATCH 06/34] =?UTF-8?q?docs:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EC=97=90=20=EB=AA=A8=EC=A7=91=20=EA=B8=B0=EA=B0=84=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/api/app/event/locker/AppLockerApi.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 cf75b921b..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( From f12f495a0395290c569a917c47c331112005306d Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 17 Oct 2026 17:17:32 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EC=82=AC=EC=A7=84=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/event/locker/AppLockerApi.java | 6 ++--- .../app/event/locker/AppLockerController.java | 12 ++++++--- .../response/LockerSectionDetailResponse.java | 27 ++++++++++++++----- .../locker/domain/LockerSectionLayout.java | 14 +++++++--- .../event/LockerSectionLayoutJpaEntity.java | 12 ++++++--- ..._locker_section_layout_photo_with_list.sql | 14 ++++++++++ 6 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql 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 7f91a1eb7..9229ce98d 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 @@ -40,9 +40,9 @@ ApiResponse getSections( @Operation(summary = "사물함 구역 상세 조회", description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. " + "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 " - + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 " - + "선택할 수 있다. 선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 " - + "LOCKER_APPLY_NOT_OPEN이 난다.") + + "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. " + + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다. " + + "선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 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( diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 19b752d2d..bc4b5e22a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; @@ -16,6 +17,7 @@ import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; @@ -70,15 +72,17 @@ public ApiResponse getSectionLockers( Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - File photo = Optional.ofNullable(detail.layout()) - .flatMap(layout -> fileService.findById(layout.getPhotoFileId())) - .orElse(null); + Map photosById = Optional.ofNullable(detail.layout()) + .map(LockerSectionLayout::getPhotoFileIds) + .filter(photoFileIds -> !photoFileIds.isEmpty()) + .map(fileService::findAllByIdIn) + .orElseGet(Map::of); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) .orElse(null); - return ApiResponse.success(LockerSectionDetailResponse.of(detail, photo, appliedLockerIds, myLockerId)); + return ApiResponse.success(LockerSectionDetailResponse.of(detail, photosById, appliedLockerIds, myLockerId)); } @Override diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java index a8868a753..ecff655e4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.annotation.JsonRawValue; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; @@ -10,25 +12,25 @@ import kr.ac.kookmin.stream.file.domain.File; /** - * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} - * @param photoUrl 구역 실제 사진. 배치 구조를 아직 등록하지 않았거나 사진 파일이 삭제됐으면 {@code null} + * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} + * @param photoUrls 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다 */ public record LockerSectionDetailResponse( Long sectionId, String section, Layout layout, - String photoUrl, + List photoUrls, List lockers ) { /** - * @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null} + * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} */ public static LockerSectionDetailResponse of( LockerSectionDetail detail, - File photo, + Map photosById, Set appliedLockerIds, Long myLockerId ) { @@ -36,13 +38,26 @@ public static LockerSectionDetailResponse of( detail.section().getId(), detail.section().getLabel(), detail.layout() == null ? null : Layout.from(detail.layout()), - StorageUrlBuilder.build(photo), + toPhotoUrls(detail, photosById), detail.lockers().stream() .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId)) .toList() ); } + /** + * 배치 구조를 등록하지 않은 구역이면 사진도 없다. 학생 앱은 사진을 보여주기만 하므로 파일 식별자 없이 URL만 담는다. + */ + private static List toPhotoUrls(LockerSectionDetail detail, Map photosById) { + if (detail.layout() == null) { + return List.of(); + } + return detail.layout().getPhotoFileIds().stream() + .map(fileId -> StorageUrlBuilder.build(fileId, photosById)) + .filter(Objects::nonNull) + .toList(); + } + /** * @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다 * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다 diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java index 83632b712..f8cc70023 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -22,9 +23,16 @@ public class LockerSectionLayout { private String layout; /** layout 형식 버전. 블록 종류·필드가 바뀌면 올려서 옛 앱이 모르는 형식을 알아채게 한다. */ private int version; - private Long photoFileId; + /** 구역 실제 사진. 배열 순서가 노출 순서다. 등록하지 않았으면 빈 목록이다. */ + private List photoFileIds; - public static LockerSectionLayout of(Long id, Long sectionId, String layout, int version, Long photoFileId) { - return new LockerSectionLayout(id, sectionId, layout, version, photoFileId); + public static LockerSectionLayout of( + Long id, + Long sectionId, + String layout, + int version, + List photoFileIds + ) { + return new LockerSectionLayout(id, sectionId, layout, version, photoFileIds); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java index 6689bd429..d3407e856 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java @@ -6,6 +6,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.util.List; import kr.ac.kookmin.stream.db.common.BaseTimeEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; import lombok.AccessLevel; @@ -35,15 +36,17 @@ public class LockerSectionLayoutJpaEntity extends BaseTimeEntity { @Column(nullable = false, columnDefinition = "SMALLINT") private int version; - @Column(name = "photo_file_id", nullable = false) - private Long photoFileId; + /** 구역 실제 사진. 배열 순서가 노출 순서다. 파일 목록을 JSON으로 두는 건 notices.image_ids와 같은 방식이다. */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "photo_file_ids", columnDefinition = "json") + private List photoFileIds; private LockerSectionLayoutJpaEntity(LockerSectionLayout sectionLayout) { this.id = sectionLayout.getId(); this.sectionId = sectionLayout.getSectionId(); this.layout = sectionLayout.getLayout(); this.version = sectionLayout.getVersion(); - this.photoFileId = sectionLayout.getPhotoFileId(); + this.photoFileIds = sectionLayout.getPhotoFileIds(); } public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayout) { @@ -51,6 +54,7 @@ public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayou } public LockerSectionLayout toDomain() { - return LockerSectionLayout.of(id, sectionId, layout, version, photoFileId); + // 사진을 한 장도 등록하지 않은 구역은 컬럼이 NULL이다. 읽는 쪽이 매번 null을 보지 않도록 빈 목록으로 바꾼다 + return LockerSectionLayout.of(id, sectionId, layout, version, photoFileIds == null ? List.of() : photoFileIds); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql new file mode 100644 index 000000000..412e49931 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql @@ -0,0 +1,14 @@ +-- 구역 사진을 여러 장 저장한다. 구역당 한 장 전제로 V15에서 단일 컬럼을 뒀는데, 실제로 두 장인 구역이 있다. +-- 배열 순서가 노출 순서다. +-- +-- 저장소의 다른 파일 목록 컬럼(notices.image_ids·attachment_ids, events.image_ids, archives.image_ids)과 같은 +-- JSON NULL로 둔다. JSON으로 바꾸면 "반드시 한 장"은 DB가 못 지킨다 — 빈 배열은 NULL이 아니라서 NOT NULL을 +-- 통과한다. 최소 한 장 검증은 구역 쓰기 API에서 한다. +ALTER TABLE locker_section_layouts + ADD COLUMN photo_file_ids JSON NULL; + +UPDATE locker_section_layouts +SET photo_file_ids = JSON_ARRAY(photo_file_id); + +ALTER TABLE locker_section_layouts + DROP COLUMN photo_file_id; From cad1e8ea9bdaaed8d7768ab65c739695053dd4ab Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 17 Oct 2026 17:20:33 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=20=EB=B0=B0=EC=A0=95=20=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/locker/domain/LockerSectionSummary.java | 7 ++++++- .../domain/locker/service/impl/LockerServiceImpl.java | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java index ddc681802..64891e909 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -1,13 +1,18 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; /** - * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다. + * 사물함 구역 목록 한 건. 세 개수 모두 저장값이 아니라 조회 시점에 센다. + * + * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다 + * @param totalCount 구역의 전체 사물함 수. 사용 중지된 사물함도 포함한다 + * @param assignedCount 이 회차에 배정된 사물함 수 */ public record LockerSectionSummary( Long sectionId, String label, int availableCount, int totalCount, + int assignedCount, SectionAvailabilityStatus availabilityStatus ) { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index 02c82ecf2..e0dcdbab5 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -89,12 +89,17 @@ private LockerSectionSummary summarize( int availableCount = (int) lockers.stream() .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) .count(); + // 사용 중지된 사물함도 배정돼 있으면 센다. 배정 후 고장 난 칸이라 운영진이 다른 자리를 줘야 하는 대상이다 + int assignedCount = (int) lockers.stream() + .filter(locker -> appliedLockerIds.contains(locker.getId())) + .count(); return new LockerSectionSummary( section.getId(), section.getLabel(), availableCount, totalCount, + assignedCount, SectionAvailabilityStatus.from(availableCount, totalCount) ); } From d495f0016cc24153ba630f424186bfa5a096c5e0 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 00:05:49 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/locker/AdminLockerSectionApi.java | 50 +++++++ .../locker/AdminLockerSectionController.java | 59 +++++++++ .../locker/request/LockerPeriodParams.java | 19 +++ .../AdminLockerSectionDetailResponse.java | 122 ++++++++++++++++++ .../AdminLockerSectionListResponse.java | 37 ++++++ .../app/event/locker/AppLockerController.java | 9 +- 6 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java new file mode 100644 index 000000000..37fe8ae54 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java @@ -0,0 +1,50 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 운영진 사물함 구역 API의 문서 명세. 구현은 {@link AdminLockerSectionController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + *

+ * 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다. + */ +@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회") +public interface AdminLockerSectionApi { + + /** 구역별 전체·선택 가능·배정 사물함 수. */ + @Operation(summary = "사물함 구역 목록 조회", + description = "운영 회차를 기준으로 구역별 전체 사물함 수, 선택 가능한 사물함 수, 배정된 사물함 수를 조회한다. " + + "사용 중지된 사물함과 그 회차에 배정된 사물함은 선택 가능 수에서 빠진다. " + + "배정 수는 배정된 뒤 사용 중지된 사물함도 센다. 구역은 식별자 오름차순으로 반환한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse getLockerSections( + AdminApiUser apiUser, + @ParameterObject LockerPeriodParams params + ); + + /** 구역의 칸 배치·사진과 사물함별 상태. */ + @Operation(summary = "사물함 구역 상세 조회", + description = "운영 회차를 기준으로 구역의 칸 배치 구조(layout), 구역 사진, 사물함별 상태를 조회한다. " + + "layout은 저장된 원문을 그대로 돌려주며 칸 번호는 lockers[].lockerNumber와 같다. " + + "배치 구조를 아직 등록하지 않은 구역이면 layout이 null이고 photos가 빈 배열이다. " + + "사물함 상태는 물리 상태와 그 회차의 배정 여부를 합친 값이고, 배정된 뒤 사용 중지된 칸은 ASSIGNED다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) + ApiResponse getLockerSection( + AdminApiUser apiUser, + Long sectionId, + @ParameterObject LockerPeriodParams params + ); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java new file mode 100644 index 000000000..a001e5fec --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/lockers/sections") +@RequiredArgsConstructor +public class AdminLockerSectionController implements AdminLockerSectionApi { + + private final LockerService lockerService; + private final FileService fileService; + + @Override + @GetMapping + public ApiResponse getLockerSections( + AdminApiUser apiUser, + @Valid @ModelAttribute LockerPeriodParams params + ) { + List sections = lockerService.getSections(params.lockerPeriodId()); + return ApiResponse.success(AdminLockerSectionListResponse.from(sections)); + } + + @Override + @GetMapping("/{sectionId}") + public ApiResponse getLockerSection( + AdminApiUser apiUser, + @PathVariable Long sectionId, + @Valid @ModelAttribute LockerPeriodParams params + ) { + Long lockerPeriodId = params.lockerPeriodId(); + LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); + // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 + Map photosById = fileService.findAllByIdIn( + detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); + Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); + + return ApiResponse.success( + AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java new file mode 100644 index 000000000..34e1a8b0a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.NotNull; + +/** + * 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다. + *

+ * 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가 + * {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고, + * {@code @Valid @ModelAttribute}여야 400 {@code INVALID_INPUT}으로 나간다. + * + * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 + */ +public record LockerPeriodParams( + + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java new file mode 100644 index 000000000..843895003 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java @@ -0,0 +1,122 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +import kr.ac.kookmin.stream.file.domain.File; + +/** + * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} + * @param photos 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다 + */ +public record AdminLockerSectionDetailResponse( + Long sectionId, + String section, + Layout layout, + List photos, + List lockers +) { + + /** + * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + */ + public static AdminLockerSectionDetailResponse of( + LockerSectionDetail detail, + Map photosById, + Set appliedLockerIds + ) { + return new AdminLockerSectionDetailResponse( + detail.section().getId(), + detail.section().getLabel(), + detail.layout() == null ? null : Layout.from(detail.layout()), + toPhotos(detail, photosById), + detail.lockers().stream() + .map(locker -> LockerItem.from(locker, appliedLockerIds.contains(locker.getId()))) + .toList() + ); + } + + /** 배치 구조를 등록하지 않은 구역이면 사진도 없다. */ + private static List toPhotos(LockerSectionDetail detail, Map photosById) { + if (detail.layout() == null) { + return List.of(); + } + return detail.layout().getPhotoFileIds().stream() + .filter(photosById::containsKey) + .map(fileId -> Photo.from(fileId, photosById.get(fileId))) + .toList(); + } + + /** + * @param version layout 형식 버전 + * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다 + */ + public record Layout( + int version, + @JsonRawValue + @Schema(type = "object", description = "root 블록 트리. 칸 번호는 lockers[].lockerNumber와 같다") + String root + ) { + + public static Layout from(LockerSectionLayout layout) { + return new Layout(layout.getVersion(), layout.getLayout()); + } + } + + /** 운영진은 사진을 지우거나 순서를 바꿀 때 파일 식별자가 필요하므로 URL과 함께 내려준다. */ + public record Photo(Long fileId, String fileUrl) { + + public static Photo from(Long fileId, File file) { + return new Photo(fileId, StorageUrlBuilder.build(file.getFileKey())); + } + } + + /** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */ + public record LockerItem( + Long lockerId, + int lockerNumber, + String lockerLabel, + LockerDisplayStatus status + ) { + + /** @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */ + public static LockerItem from(Locker locker, boolean applied) { + return new LockerItem( + locker.getId(), + locker.getLockerNumber(), + locker.getLockerLabel(), + LockerDisplayStatus.from(locker.getStatus(), applied) + ); + } + } + + /** + * 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라 + * 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절). + */ + public enum LockerDisplayStatus { + + AVAILABLE, + DISABLED, + ASSIGNED; + + /** + * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진이 그 학생에게 다른 자리를 줘야 하는 상황이라 + * 배정 사실이 물리 상태보다 먼저 보여야 한다. + */ + public static LockerDisplayStatus from(LockerStatus status, boolean applied) { + if (applied) { + return ASSIGNED; + } + return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED; + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java new file mode 100644 index 000000000..957fdf179 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public record AdminLockerSectionListResponse(List

sections) { + + public static AdminLockerSectionListResponse from(List summaries) { + return new AdminLockerSectionListResponse(summaries.stream() + .map(Section::from) + .toList()); + } + + /** + * @param totalCount 구역 내 전체 사물함 수. 사용 중지된 사물함도 포함한다 + * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다 + * @param assignedCount 이 회차에 배정된 사물함 수. 배정된 뒤 사용 중지된 사물함도 센다 + */ + public record Section( + Long sectionId, + String section, + int totalCount, + int availableCount, + int assignedCount + ) { + + public static Section from(LockerSectionSummary summary) { + return new Section( + summary.sectionId(), + summary.label(), + summary.totalCount(), + summary.availableCount(), + summary.assignedCount() + ); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index bc4b5e22a..454e54d57 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -4,7 +4,6 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerApplyRequest; @@ -17,7 +16,6 @@ import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; @@ -72,11 +70,8 @@ public ApiResponse getSectionLockers( Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - Map photosById = Optional.ofNullable(detail.layout()) - .map(LockerSectionLayout::getPhotoFileIds) - .filter(photoFileIds -> !photoFileIds.isEmpty()) - .map(fileService::findAllByIdIn) - .orElseGet(Map::of); + Map photosById = fileService.findAllByIdIn( + detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) From 5a348b6dc2f1288bb49480d7478a1b6a7b8f7467 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 20:08:37 +0900 Subject: [PATCH 10/34] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=9D=98=20=EC=B9=B8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20Locker.isSelectable=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/AdminLockerSectionDetailResponse.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java index 843895003..4c524008f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java @@ -93,7 +93,7 @@ public static LockerItem from(Locker locker, boolean applied) { locker.getId(), locker.getLockerNumber(), locker.getLockerLabel(), - LockerDisplayStatus.from(locker.getStatus(), applied) + LockerDisplayStatus.from(locker, applied) ); } } @@ -109,14 +109,15 @@ public enum LockerDisplayStatus { ASSIGNED; /** - * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진이 그 학생에게 다른 자리를 줘야 하는 상황이라 - * 배정 사실이 물리 상태보다 먼저 보여야 한다. + * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진 화면이 칸 하나를 색 하나로 그려서 둘 중 + * 하나만 고를 수 있고, 배정된 학생이 있다는 사실을 먼저 보여야 한다. */ - public static LockerDisplayStatus from(LockerStatus status, boolean applied) { + public static LockerDisplayStatus from(Locker locker, boolean applied) { if (applied) { return ASSIGNED; } - return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED; + // 구역 목록의 availableCount와 같은 기준으로 가른다. 배정 여부는 위에서 이미 갈랐으므로 false를 넘긴다 + return locker.isSelectable(false) ? AVAILABLE : DISABLED; } } } From 768a0fdd225964413d43054972a507a8ae7c16d2 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 20:11:00 +0900 Subject: [PATCH 11/34] =?UTF-8?q?refactor:=20=EA=B0=92=20=EB=91=90=20?= =?UTF-8?q?=EA=B0=9C=EB=A5=BC=20=EB=B0=9B=EB=8A=94=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A6=AC=EB=A5=BC=20of=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/response/AdminLockerSectionDetailResponse.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java index 4c524008f..585513b2c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java @@ -39,7 +39,7 @@ public static AdminLockerSectionDetailResponse of( detail.layout() == null ? null : Layout.from(detail.layout()), toPhotos(detail, photosById), detail.lockers().stream() - .map(locker -> LockerItem.from(locker, appliedLockerIds.contains(locker.getId()))) + .map(locker -> LockerItem.of(locker, appliedLockerIds.contains(locker.getId()))) .toList() ); } @@ -51,7 +51,7 @@ private static List toPhotos(LockerSectionDetail detail, Map } return detail.layout().getPhotoFileIds().stream() .filter(photosById::containsKey) - .map(fileId -> Photo.from(fileId, photosById.get(fileId))) + .map(fileId -> Photo.of(fileId, photosById.get(fileId))) .toList(); } @@ -74,7 +74,7 @@ public static Layout from(LockerSectionLayout layout) { /** 운영진은 사진을 지우거나 순서를 바꿀 때 파일 식별자가 필요하므로 URL과 함께 내려준다. */ public record Photo(Long fileId, String fileUrl) { - public static Photo from(Long fileId, File file) { + public static Photo of(Long fileId, File file) { return new Photo(fileId, StorageUrlBuilder.build(file.getFileKey())); } } @@ -88,7 +88,7 @@ public record LockerItem( ) { /** @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */ - public static LockerItem from(Locker locker, boolean applied) { + public static LockerItem of(Locker locker, boolean applied) { return new LockerItem( locker.getId(), locker.getLockerNumber(), From 0fb9a887e090dbf98bec90f2bc7b9094008cb4e8 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 20:13:40 +0900 Subject: [PATCH 12/34] =?UTF-8?q?docs:=20V21=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=EC=97=90=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EC=A0=84=20=ED=99=95=EC=9D=B8=20=EC=82=AC=ED=95=AD=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V21__replace_locker_section_layout_photo_with_list.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql index 412e49931..464bbc9a4 100644 --- a/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql +++ b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql @@ -4,6 +4,11 @@ -- 저장소의 다른 파일 목록 컬럼(notices.image_ids·attachment_ids, events.image_ids, archives.image_ids)과 같은 -- JSON NULL로 둔다. JSON으로 바꾸면 "반드시 한 장"은 DB가 못 지킨다 — 빈 배열은 NULL이 아니라서 NOT NULL을 -- 통과한다. 최소 한 장 검증은 구역 쓰기 API에서 한다. +-- +-- ⚠️ 한 파일에 컬럼 추가·백필·DROP이 함께 있는데 MySQL은 DDL을 트랜잭션으로 묶지 않는다. 중간에 실패하면 앞 문장이 +-- 이미 적용된 채로 남아 손으로 되돌려야 한다. +-- 그리고 photo_file_id를 지운 뒤에는 이전 이미지로 롤백할 수 없다 — ddl-auto: validate가 없는 컬럼을 찾아 기동을 막는다. +-- 적용 전 SELECT COUNT(*) FROM locker_section_layouts 로 행 수를 확인한다. 0건이면 백필이 건드릴 행도 없다. ALTER TABLE locker_section_layouts ADD COLUMN photo_file_ids JSON NULL; From 5d5c1a23134b8ae937863f91ddc6fe648b5d5c45 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 20:17:56 +0900 Subject: [PATCH 13/34] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=8B=9D=EB=B3=84=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20LockerSectionDetail=EB=A1=9C=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/event/locker/AdminLockerSectionController.java | 4 +--- .../locker/response/AdminLockerSectionDetailResponse.java | 6 +----- .../stream/api/app/event/locker/AppLockerController.java | 4 +--- .../locker/response/LockerSectionDetailResponse.java | 8 ++------ .../event/domain/locker/domain/LockerSectionDetail.java | 5 +++++ 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java index a001e5fec..50a670876 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -48,9 +48,7 @@ public ApiResponse getLockerSection( ) { Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); - // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - Map photosById = fileService.findAllByIdIn( - detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); + Map photosById = fileService.findAllByIdIn(detail.photoFileIds()); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); return ApiResponse.success( diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java index 585513b2c..c4c2482ab 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java @@ -44,12 +44,8 @@ public static AdminLockerSectionDetailResponse of( ); } - /** 배치 구조를 등록하지 않은 구역이면 사진도 없다. */ private static List toPhotos(LockerSectionDetail detail, Map photosById) { - if (detail.layout() == null) { - return List.of(); - } - return detail.layout().getPhotoFileIds().stream() + return detail.photoFileIds().stream() .filter(photosById::containsKey) .map(fileId -> Photo.of(fileId, photosById.get(fileId))) .toList(); diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 454e54d57..f2f9d883d 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -69,9 +69,7 @@ public ApiResponse getSectionLockers( ) { Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); - // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - Map photosById = fileService.findAllByIdIn( - detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); + Map photosById = fileService.findAllByIdIn(detail.photoFileIds()); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java index ecff655e4..3300b7cd9 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; @@ -49,12 +48,9 @@ public static LockerSectionDetailResponse of( * 배치 구조를 등록하지 않은 구역이면 사진도 없다. 학생 앱은 사진을 보여주기만 하므로 파일 식별자 없이 URL만 담는다. */ private static List toPhotoUrls(LockerSectionDetail detail, Map photosById) { - if (detail.layout() == null) { - return List.of(); - } - return detail.layout().getPhotoFileIds().stream() + return detail.photoFileIds().stream() + .filter(photosById::containsKey) .map(fileId -> StorageUrlBuilder.build(fileId, photosById)) - .filter(Objects::nonNull) .toList(); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java index 08c4d1510..319dca160 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java @@ -13,4 +13,9 @@ public record LockerSectionDetail( LockerSectionLayout layout, List lockers ) { + + /** 구역 사진 파일 식별자를 노출 순서대로. 배치 구조를 아직 등록하지 않은 구역이면 사진도 없어 빈 목록이다. */ + public List photoFileIds() { + return layout == null ? List.of() : layout.getPhotoFileIds(); + } } From f9a9030bc8336914ece29e7e881cb7b08ed3133e Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 03:32:53 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=99=EC=9E=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/locker/domain/Locker.java | 13 +++++++++++++ .../event/domain/locker/domain/LockerErrorCode.java | 2 ++ 2 files changed, 15 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index 552dc6f8d..5bf1ef7b7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -42,4 +42,17 @@ public static Locker of( public boolean isSelectable(boolean applied) { return status == LockerStatus.AVAILABLE && !applied; } + + /** + * 사물함 자체 상태를 바꾼다. 바뀐 값이 있으면 true를 돌려준다. + *

+ * 운영진이 여러 칸을 한 번에 바꾸므로, 이미 그 상태인 사물함은 저장하지 않도록 바뀌었는지 알려준다. + */ + public boolean changeStatus(LockerStatus status) { + if (this.status == status) { + return false; + } + this.status = status; + return true; + } } 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 efcf6c636..83be320f2 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 @@ -20,6 +20,8 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."), LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."), LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."), + LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."), + LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."), LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."); private final int status; From 5bcfc2ed35b5cab757be178005e069f3f2080a59 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 03:32:53 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/LockerApplicationRepository.java | 6 ++++++ .../domain/locker/repository/LockerRepository.java | 5 +++++ .../impl/LockerApplicationServiceImplTest.java | 10 ++++++++++ .../locker/service/impl/LockerServiceImplTest.java | 10 ++++++++++ .../db/event/LockerApplicationJpaRepository.java | 3 +++ .../db/event/LockerApplicationRepositoryImpl.java | 11 +++++++++++ .../kookmin/stream/db/event/LockerRepositoryImpl.java | 7 +++++++ 7 files changed, 52 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java index 6ee3936d4..248f83650 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -35,6 +36,11 @@ public interface LockerApplicationRepository { */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); + /** + * 해당 사물함들의 신청 전체. 어느 운영 회차에 배정됐는지 보려고 읽는다. + */ + List findByLockerIds(Collection lockerIds); + /** * 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 150da3542..5ea2fb699 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -92,4 +92,9 @@ public interface LockerRepository { * 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다. */ List findLockersByIdsIncludingDeleted(Collection lockerIds); + + /** + * 사물함 여러 건을 저장한다. 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다. + */ + void saveLockers(List lockers); } 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 6c3010160..14d7bf7a0 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 @@ -360,6 +360,11 @@ public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt throw new UnsupportedOperationException(); } + @Override + public void saveLockers(List lockers) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { batchLookups++; @@ -428,6 +433,11 @@ private static final class FakeLockerApplicationRepository implements LockerAppl private final List saved = new ArrayList<>(); private List memberApplications = List.of(); + @Override + public List findByLockerIds(Collection lockerIds) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsByPeriod(Long lockerPeriodId) { throw new UnsupportedOperationException(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index e6b8ae9fe..111416873 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -323,6 +323,11 @@ public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt throw new UnsupportedOperationException(); } + @Override + public void saveLockers(List lockers) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { throw new UnsupportedOperationException(); @@ -351,6 +356,11 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + @Override + public List findByLockerIds(Collection lockerIds) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsByPeriod(Long lockerPeriodId) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index e5694261f..254ead196 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -30,6 +31,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); + List findAllByLockerIdIn(Collection lockerIds); + /** * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. */ diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index e91dcfce6..c8dab7c73 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -45,6 +46,16 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); } + @Override + public List findByLockerIds(Collection lockerIds) { + if (lockerIds.isEmpty()) { + return List.of(); + } + return lockerApplicationJpaRepository.findAllByLockerIdIn(lockerIds).stream() + .map(LockerApplicationJpaEntity::toDomain) + .toList(); + } + @Override public List findByMemberId(Long memberId) { return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream() diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java index 1e86220b5..460d54056 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -138,6 +138,13 @@ public Optional findLockerById(Long lockerId) { .map(LockerJpaEntity::toDomain); } + @Override + public void saveLockers(List lockers) { + lockerJpaRepository.saveAll(lockers.stream() + .map(LockerJpaEntity::from) + .toList()); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { return lockerJpaRepository.findAllByIdIn(lockerIds).stream() From 4c5b2ef9db5ed6932aef29d55c42335168b19b48 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 03:32:53 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=BC=EA=B4=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/service/AdminLockerService.java | 12 ++++ .../service/impl/AdminLockerServiceImpl.java | 69 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java index 65ba2420a..ca9df54ec 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java @@ -1,9 +1,11 @@ package kr.ac.kookmin.stream.event.domain.locker.service; import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; /** * 운영진 사물함 API가 쓰는 서비스. @@ -36,4 +38,14 @@ public interface AdminLockerService { * 운영 회차를 소프트 삭제한다. 신청이 한 건이라도 있으면 거부한다. */ void deletePeriod(Long lockerPeriodId); + + /** + * 구역 안의 사물함 여러 개의 상태를 한 번에 바꾸고, 바뀐 사물함을 돌려준다. + *

+ * 하나라도 바꿀 수 없으면 아무것도 바꾸지 않는다. 사용 기간이 끝나지 않은 회차에 배정된 사물함은 + * 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다. + * + * @return 요청한 사물함 전체. 이미 그 상태였던 사물함도 포함한다 + */ + List changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index 6247affd5..d2d451127 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -1,13 +1,22 @@ package kr.ac.kookmin.stream.event.domain.locker.service.impl; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; @@ -66,6 +75,66 @@ public void deletePeriod(Long lockerPeriodId) { } } + @Override + @Transactional + public List changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) { + List lockers = findLockersInSection(sectionId, lockerIds); + if (status == LockerStatus.DISABLED) { + requireNotInUse(lockerIds); + } + + // 이미 그 상태인 사물함은 저장하지 않는다. 36칸 구역에서 두 칸만 바꿔도 전체를 쓰지 않도록 한다 + lockerRepository.saveLockers(lockers.stream() + .filter(locker -> locker.changeStatus(status)) + .toList()); + return lockers; + } + + /** + * 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다. + */ + private List findLockersInSection(Long sectionId, List lockerIds) { + Map lockersInSection = lockerRepository.findLockersBySectionId(sectionId).stream() + .collect(Collectors.toMap(Locker::getId, Function.identity())); + + return lockerIds.stream() + .map(lockerId -> { + Locker locker = lockersInSection.get(lockerId); + if (locker == null) { + throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION); + } + return locker; + }) + .toList(); + } + + /** + * 아직 배정 상태인 신청이 걸린 사물함이 하나라도 있으면 끊는다. + *

+ * 배정인지 만료인지는 내 신청 내역 화면과 같은 기준을 써야 하므로 {@link LockerApplicationStatus}에 맡긴다. + * 저장소는 신청과 회차를 읽어 오기만 한다. + */ + private void requireNotInUse(List lockerIds) { + List applications = lockerApplicationRepository.findByLockerIds(lockerIds); + if (applications.isEmpty()) { + return; + } + + LocalDate today = LocalDate.now(); + Set assignedPeriodIds = lockerRepository.findPublishedPeriodsByIds(applications.stream() + .map(LockerApplication::getLockerPeriodId) + .collect(Collectors.toSet())).stream() + .filter(period -> LockerApplicationStatus.from(period, today) == LockerApplicationStatus.ASSIGNED) + .map(LockerPeriod::getId) + .collect(Collectors.toSet()); + + boolean inUse = applications.stream() + .anyMatch(application -> assignedPeriodIds.contains(application.getLockerPeriodId())); + if (inUse) { + throw new BusinessException(LockerErrorCode.LOCKER_IN_USE); + } + } + private LockerPeriod getPeriod(Long lockerPeriodId) { return lockerRepository.findPeriodById(lockerPeriodId) .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); From 9654f0c09cedc4ac33343c689f6c2255f38c735f Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 03:32:53 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=BC=EA=B4=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/locker/AdminLockerSectionApi.java | 20 ++++++++++++++- .../locker/AdminLockerSectionController.java | 19 ++++++++++++++ .../request/LockerStatusChangeRequest.java | 20 +++++++++++++++ .../AdminLockerStatusChangeResponse.java | 25 +++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java index 37fe8ae54..1dc041bc4 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java @@ -4,8 +4,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; @@ -19,7 +21,7 @@ *

* 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다. */ -@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회") +@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회와 사물함 상태 변경") public interface AdminLockerSectionApi { /** 구역별 전체·선택 가능·배정 사물함 수. */ @@ -47,4 +49,20 @@ ApiResponse getLockerSection( Long sectionId, @ParameterObject LockerPeriodParams params ); + + /** 구역 안의 사물함 여러 개를 같은 상태로 바꾼다. */ + @Operation(summary = "사물함 상태 일괄 변경", + description = "구역 안의 사물함 여러 개를 AVAILABLE(선택가능) 또는 DISABLED(선택불가)로 한 번에 바꾼다. " + + "하나라도 바꿀 수 없으면 아무것도 바뀌지 않는다. " + + "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, " + + "AVAILABLE로 되돌리는 것은 막지 않는다. " + + "응답은 요청한 사물함 전체로, 이미 그 상태였던 사물함도 담긴다. " + + "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"}) + ApiResponse changeLockerStatus( + AdminApiUser apiUser, + Long sectionId, + LockerStatusChangeRequest request + ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java index 50a670876..504262a20 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -6,18 +6,24 @@ import java.util.Set; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -27,6 +33,7 @@ public class AdminLockerSectionController implements AdminLockerSectionApi { private final LockerService lockerService; + private final AdminLockerService adminLockerService; private final FileService fileService; @Override @@ -54,4 +61,16 @@ public ApiResponse getLockerSection( return ApiResponse.success( AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds)); } + + @Override + @PatchMapping("/{sectionId}/lockers/status") + public ApiResponse changeLockerStatus( + AdminApiUser apiUser, + @PathVariable Long sectionId, + @Valid @RequestBody LockerStatusChangeRequest request + ) { + List lockers = adminLockerService.changeLockerStatus( + sectionId, request.lockerIds(), request.lockerStatus()); + return ApiResponse.success(AdminLockerStatusChangeResponse.from(lockers)); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java new file mode 100644 index 000000000..a1759a0e7 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; + +/** + * 구역 안의 사물함 여러 개를 같은 상태로 바꾼다. + *

+ * 구역 상세가 내려주는 {@code ASSIGNED}는 신청으로 생기는 표시용 값이라 이 경로로는 바꿀 수 없다. + */ +public record LockerStatusChangeRequest( + @NotEmpty(message = "사물함을 하나 이상 선택해 주세요.") + List<@NotNull(message = "사물함 ID는 비워 둘 수 없습니다.") Long> lockerIds, + + @NotNull(message = "변경할 상태를 입력해 주세요.") + LockerStatus lockerStatus +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java new file mode 100644 index 000000000..037233acc --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; + +/** + * 요청한 사물함 전체를 바뀐 뒤 상태로 돌려준다. 이미 그 상태였던 사물함도 담긴다. + */ +public record AdminLockerStatusChangeResponse(List lockers) { + + public static AdminLockerStatusChangeResponse from(List lockers) { + return new AdminLockerStatusChangeResponse(lockers.stream() + .map(LockerItem::from) + .toList()); + } + + /** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */ + public record LockerItem(Long lockerId, int lockerNumber, LockerStatus lockerStatus) { + + public static LockerItem from(Locker locker) { + return new LockerItem(locker.getId(), locker.getLockerNumber(), locker.getStatus()); + } + } +} From e39dca0645a166f4a0239f532fa84a62029a08e9 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 20:55:07 +0900 Subject: [PATCH 18/34] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EC=97=86=EC=95=A0=EA=B3=A0=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EC=9D=84=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/locker/AdminLockerSectionApi.java | 7 +++--- .../locker/AdminLockerSectionController.java | 9 +++---- .../AdminLockerStatusChangeResponse.java | 25 ------------------- .../event/domain/locker/domain/Locker.java | 10 ++------ .../locker/service/AdminLockerService.java | 7 ++---- .../service/impl/AdminLockerServiceImpl.java | 9 +++---- 6 files changed, 13 insertions(+), 54 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java index 1dc041bc4..5f6bd29aa 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java @@ -7,7 +7,6 @@ import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; -import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; @@ -56,11 +55,11 @@ ApiResponse getLockerSection( + "하나라도 바꿀 수 없으면 아무것도 바뀌지 않는다. " + "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, " + "AVAILABLE로 되돌리는 것은 막지 않는다. " - + "응답은 요청한 사물함 전체로, 이미 그 상태였던 사물함도 담긴다. " - + "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다.") + + "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다. " + + "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"}) - ApiResponse changeLockerStatus( + ApiResponse changeLockerStatus( AdminApiUser apiUser, Long sectionId, LockerStatusChangeRequest request diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java index 504262a20..33732f0a0 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -9,9 +9,7 @@ import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; -import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; @@ -64,13 +62,12 @@ public ApiResponse getLockerSection( @Override @PatchMapping("/{sectionId}/lockers/status") - public ApiResponse changeLockerStatus( + public ApiResponse changeLockerStatus( AdminApiUser apiUser, @PathVariable Long sectionId, @Valid @RequestBody LockerStatusChangeRequest request ) { - List lockers = adminLockerService.changeLockerStatus( - sectionId, request.lockerIds(), request.lockerStatus()); - return ApiResponse.success(AdminLockerStatusChangeResponse.from(lockers)); + adminLockerService.changeLockerStatus(sectionId, request.lockerIds(), request.lockerStatus()); + return ApiResponse.success(); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java deleted file mode 100644 index 037233acc..000000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerStatusChangeResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.event.locker.response; - -import java.util.List; -import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; - -/** - * 요청한 사물함 전체를 바뀐 뒤 상태로 돌려준다. 이미 그 상태였던 사물함도 담긴다. - */ -public record AdminLockerStatusChangeResponse(List lockers) { - - public static AdminLockerStatusChangeResponse from(List lockers) { - return new AdminLockerStatusChangeResponse(lockers.stream() - .map(LockerItem::from) - .toList()); - } - - /** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */ - public record LockerItem(Long lockerId, int lockerNumber, LockerStatus lockerStatus) { - - public static LockerItem from(Locker locker) { - return new LockerItem(locker.getId(), locker.getLockerNumber(), locker.getStatus()); - } - } -} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index 5bf1ef7b7..c724433fe 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -44,15 +44,9 @@ public boolean isSelectable(boolean applied) { } /** - * 사물함 자체 상태를 바꾼다. 바뀐 값이 있으면 true를 돌려준다. - *

- * 운영진이 여러 칸을 한 번에 바꾸므로, 이미 그 상태인 사물함은 저장하지 않도록 바뀌었는지 알려준다. + * 사물함 자체 상태를 바꾼다. 이미 그 상태여도 그대로 둔다 — 영속성 컨텍스트가 값이 바뀐 행만 UPDATE한다. */ - public boolean changeStatus(LockerStatus status) { - if (this.status == status) { - return false; - } + public void changeStatus(LockerStatus status) { this.status = status; - return true; } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java index ca9df54ec..2cd2da49e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java @@ -1,7 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.service; import java.util.List; -import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand; @@ -40,12 +39,10 @@ public interface AdminLockerService { void deletePeriod(Long lockerPeriodId); /** - * 구역 안의 사물함 여러 개의 상태를 한 번에 바꾸고, 바뀐 사물함을 돌려준다. + * 구역 안의 사물함 여러 개의 상태를 한 번에 바꾼다. *

* 하나라도 바꿀 수 없으면 아무것도 바꾸지 않는다. 사용 기간이 끝나지 않은 회차에 배정된 사물함은 * 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다. - * - * @return 요청한 사물함 전체. 이미 그 상태였던 사물함도 포함한다 */ - List changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status); + void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index d2d451127..bb7c83b58 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -77,17 +77,14 @@ public void deletePeriod(Long lockerPeriodId) { @Override @Transactional - public List changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) { + public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) { List lockers = findLockersInSection(sectionId, lockerIds); if (status == LockerStatus.DISABLED) { requireNotInUse(lockerIds); } - // 이미 그 상태인 사물함은 저장하지 않는다. 36칸 구역에서 두 칸만 바꿔도 전체를 쓰지 않도록 한다 - lockerRepository.saveLockers(lockers.stream() - .filter(locker -> locker.changeStatus(status)) - .toList()); - return lockers; + lockers.forEach(locker -> locker.changeStatus(status)); + lockerRepository.saveLockers(lockers); } /** From 502217149aae0b185a5760236555d260d1c6d4d6 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:00:32 +0900 Subject: [PATCH 19/34] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20LockerStatus.isUsable=EB=A1=9C=20=EB=AA=A8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/locker/domain/Locker.java | 2 +- .../event/domain/locker/domain/LockerStatus.java | 12 +++++++++++- .../locker/service/impl/AdminLockerServiceImpl.java | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index c724433fe..1df0b67b8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -40,7 +40,7 @@ public static Locker of( * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */ public boolean isSelectable(boolean applied) { - return status == LockerStatus.AVAILABLE && !applied; + return status.isUsable() && !applied; } /** diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java index adba7ae9b..a76f8d6ec 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java @@ -1,6 +1,16 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; +/** 사물함 자체의 물리 상태. 회차 배정 여부는 담지 않는다. */ public enum LockerStatus { + AVAILABLE, - DISABLED + DISABLED; + + /** + * 학생에게 내줄 수 있는 상태인지. 쓸 수 있는 상태를 허용 목록으로 두어, 나중에 상태가 늘어도 그 값이 자동으로 + * "쓸 수 없음"에 들어가게 한다. 상태를 차단 목록으로 보는 곳이 생기면 새 값만 조용히 빠져나간다. + */ + public boolean isUsable() { + return this == AVAILABLE; + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index bb7c83b58..e3f148175 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -79,7 +79,7 @@ public void deletePeriod(Long lockerPeriodId) { @Transactional public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) { List lockers = findLockersInSection(sectionId, lockerIds); - if (status == LockerStatus.DISABLED) { + if (!status.isUsable()) { requireNotInUse(lockerIds); } From a54d64c0a46ffaf83833f87e1fd887007cb2f5a2 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:11:34 +0900 Subject: [PATCH 20/34] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=9D=B4=EC=9A=A9=20=EC=A4=91=20=ED=8C=90=EC=A0=95=EC=9D=84?= =?UTF-8?q?=20=ED=9A=8C=EC=B0=A8=20=EB=A8=BC=EC=A0=80=20=EC=A2=81=ED=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=88=9C=EC=84=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/LockerApplicationRepository.java | 4 ++-- .../service/impl/AdminLockerServiceImpl.java | 16 +++++----------- .../impl/LockerApplicationServiceImplTest.java | 2 +- .../service/impl/LockerServiceImplTest.java | 2 +- .../db/event/LockerApplicationJpaRepository.java | 2 +- .../event/LockerApplicationRepositoryImpl.java | 10 ++++------ 6 files changed, 14 insertions(+), 22 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java index 248f83650..0b72feb2f 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -37,9 +37,9 @@ public interface LockerApplicationRepository { Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); /** - * 해당 사물함들의 신청 전체. 어느 운영 회차에 배정됐는지 보려고 읽는다. + * 해당 회차들에 이 사물함 중 하나라도 신청됐는지. (회차, 사물함) 유니크 인덱스를 그대로 탄다. */ - List findByLockerIds(Collection lockerIds); + boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds); /** * 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다. diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index e3f148175..807dbf5ed 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -112,22 +112,16 @@ private List findLockersInSection(Long sectionId, List lockerIds) * 저장소는 신청과 회차를 읽어 오기만 한다. */ private void requireNotInUse(List lockerIds) { - List applications = lockerApplicationRepository.findByLockerIds(lockerIds); - if (applications.isEmpty()) { - return; - } - LocalDate today = LocalDate.now(); - Set assignedPeriodIds = lockerRepository.findPublishedPeriodsByIds(applications.stream() - .map(LockerApplication::getLockerPeriodId) - .collect(Collectors.toSet())).stream() + // 회차를 먼저 좁힌다. 회차는 학기당 한두 건이라 전부 읽어도 되고, 그래야 신청 조회가 (회차, 사물함) + // 유니크 인덱스를 탄다. 사물함 식별자만으로 신청을 찾으면 탈 인덱스가 없어 이력 전체를 훑는다. + // 게시 여부로 거르지 않는다. 신청은 게시된 회차에만 들어와 지금은 차이가 없고, 거르려면 포트만 하나 더 는다 + Set assignedPeriodIds = lockerRepository.findAllPeriods().stream() .filter(period -> LockerApplicationStatus.from(period, today) == LockerApplicationStatus.ASSIGNED) .map(LockerPeriod::getId) .collect(Collectors.toSet()); - boolean inUse = applications.stream() - .anyMatch(application -> assignedPeriodIds.contains(application.getLockerPeriodId())); - if (inUse) { + if (lockerApplicationRepository.existsByPeriodIdsAndLockerIds(assignedPeriodIds, lockerIds)) { throw new BusinessException(LockerErrorCode.LOCKER_IN_USE); } } 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 14d7bf7a0..66c31e3b2 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 @@ -434,7 +434,7 @@ private static final class FakeLockerApplicationRepository implements LockerAppl private List memberApplications = List.of(); @Override - public List findByLockerIds(Collection lockerIds) { + public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) { throw new UnsupportedOperationException(); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 111416873..91d37bfee 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -357,7 +357,7 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override - public List findByLockerIds(Collection lockerIds) { + public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) { throw new UnsupportedOperationException(); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index 254ead196..ecc0552fb 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -31,7 +31,7 @@ public interface LockerApplicationJpaRepository extends JpaRepository findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); - List findAllByLockerIdIn(Collection lockerIds); + boolean existsByLockerPeriodIdInAndLockerIdIn(Collection lockerPeriodIds, Collection lockerIds); /** * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index c8dab7c73..374fc52a5 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -47,13 +47,11 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { } @Override - public List findByLockerIds(Collection lockerIds) { - if (lockerIds.isEmpty()) { - return List.of(); + public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) { + if (lockerPeriodIds.isEmpty() || lockerIds.isEmpty()) { + return false; } - return lockerApplicationJpaRepository.findAllByLockerIdIn(lockerIds).stream() - .map(LockerApplicationJpaEntity::toDomain) - .toList(); + return lockerApplicationJpaRepository.existsByLockerPeriodIdInAndLockerIdIn(lockerPeriodIds, lockerIds); } @Override From e09fcccd25da58e1c24585a7844e4733fd6eae18 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:13:50 +0900 Subject: [PATCH 21/34] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EC=9D=98=20private=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=ED=8C=8C=EC=9D=BC=20=EA=B4=80=ED=96=89?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/service/impl/AdminLockerServiceImpl.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index 807dbf5ed..dcdf219fa 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -78,9 +78,9 @@ public void deletePeriod(Long lockerPeriodId) { @Override @Transactional public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) { - List lockers = findLockersInSection(sectionId, lockerIds); + List lockers = getLockersInSection(sectionId, lockerIds); if (!status.isUsable()) { - requireNotInUse(lockerIds); + validateNotInUse(lockerIds); } lockers.forEach(locker -> locker.changeStatus(status)); @@ -90,7 +90,7 @@ public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatu /** * 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다. */ - private List findLockersInSection(Long sectionId, List lockerIds) { + private List getLockersInSection(Long sectionId, List lockerIds) { Map lockersInSection = lockerRepository.findLockersBySectionId(sectionId).stream() .collect(Collectors.toMap(Locker::getId, Function.identity())); @@ -109,9 +109,9 @@ private List findLockersInSection(Long sectionId, List lockerIds) * 아직 배정 상태인 신청이 걸린 사물함이 하나라도 있으면 끊는다. *

* 배정인지 만료인지는 내 신청 내역 화면과 같은 기준을 써야 하므로 {@link LockerApplicationStatus}에 맡긴다. - * 저장소는 신청과 회차를 읽어 오기만 한다. + * 저장소는 판정에 끼지 않고 회차를 읽고 신청 존재만 알려준다. */ - private void requireNotInUse(List lockerIds) { + private void validateNotInUse(List lockerIds) { LocalDate today = LocalDate.now(); // 회차를 먼저 좁힌다. 회차는 학기당 한두 건이라 전부 읽어도 되고, 그래야 신청 조회가 (회차, 사물함) // 유니크 인덱스를 탄다. 사물함 식별자만으로 신청을 찾으면 탈 인덱스가 없어 이력 전체를 훑는다. From 92b7df292f450ac075cf52f99aaadfbaaf48df0d Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:19:55 +0900 Subject: [PATCH 22/34] =?UTF-8?q?fix:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=B4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20@DynamicUpda?= =?UTF-8?q?te=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java index 9c38a8afd..e19cd69ae 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java @@ -14,6 +14,7 @@ import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; @Entity @Table( @@ -23,6 +24,10 @@ columnList = "section_id, is_deleted, row_no, column_no" ) ) +// 바뀐 컬럼만 UPDATE한다. 상태 변경은 도메인에서 만든 엔티티를 merge하는데, 그 엔티티는 삭제 여부를 들고 있지 않아 +// is_deleted가 늘 false다. 사물함 삭제 기능이 붙은 뒤 수정과 삭제가 겹치면 읽어 둔 값으로 되써서 삭제가 되돌아간다 +// (LockerPeriodJpaEntity와 같은 이유). +@DynamicUpdate @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LockerJpaEntity extends BaseSoftDeleteEntity { From 09061dace08468fca66cd8e59d33db2851d73f93 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:20:52 +0900 Subject: [PATCH 23/34] =?UTF-8?q?fix:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EC=8B=9D=EB=B3=84=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/event/locker/AdminLockerSectionController.java | 2 +- .../event/locker/request/LockerStatusChangeRequest.java | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java index 33732f0a0..1cbd5a8d9 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -67,7 +67,7 @@ public ApiResponse changeLockerStatus( @PathVariable Long sectionId, @Valid @RequestBody LockerStatusChangeRequest request ) { - adminLockerService.changeLockerStatus(sectionId, request.lockerIds(), request.lockerStatus()); + adminLockerService.changeLockerStatus(sectionId, request.distinctLockerIds(), request.lockerStatus()); return ApiResponse.success(); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java index a1759a0e7..f3d79138c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java @@ -17,4 +17,12 @@ public record LockerStatusChangeRequest( @NotNull(message = "변경할 상태를 입력해 주세요.") LockerStatus lockerStatus ) { + + /** + * 중복을 걷어낸 사물함 식별자. 같은 칸이 여러 번 와도 결과는 같지만, 그대로 두면 이용 중 검사의 {@code IN} 절이 + * 구역의 칸 수보다 커진다. 중복을 없애면 상한이 그 구역의 칸 수가 되어 따로 숫자를 둘 필요가 없다. + */ + public List distinctLockerIds() { + return lockerIds.stream().distinct().toList(); + } } From 5cb627dba6f96569d463a1dcc1b0ea9366282294 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:29:15 +0900 Subject: [PATCH 24/34] =?UTF-8?q?fix:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B5=AC=EC=97=AD=EC=9D=84=20404=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=ED=95=98=EA=B3=A0=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=ED=95=9C=20=EC=82=AC=EB=AC=BC=ED=95=A8=EB=A7=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/repository/LockerRepository.java | 5 ++++ .../service/impl/AdminLockerServiceImpl.java | 28 +++++++++---------- .../LockerApplicationServiceImplTest.java | 5 ++++ .../service/impl/LockerServiceImplTest.java | 5 ++++ .../stream/db/event/LockerJpaRepository.java | 2 ++ .../stream/db/event/LockerRepositoryImpl.java | 10 +++++++ 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 5ea2fb699..c75f15afb 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -93,6 +93,11 @@ public interface LockerRepository { */ List findLockersByIdsIncludingDeleted(Collection lockerIds); + /** + * 식별자에 해당하는 사물함. 삭제된 사물함은 없는 것으로 본다. 순서는 보장하지 않는다. + */ + List findLockersByIds(Collection lockerIds); + /** * 사물함 여러 건을 저장한다. 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index dcdf219fa..ffcbeace8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -3,9 +3,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -91,18 +89,20 @@ public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatu * 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다. */ private List getLockersInSection(Long sectionId, List lockerIds) { - Map lockersInSection = lockerRepository.findLockersBySectionId(sectionId).stream() - .collect(Collectors.toMap(Locker::getId, Function.identity())); - - return lockerIds.stream() - .map(lockerId -> { - Locker locker = lockersInSection.get(lockerId); - if (locker == null) { - throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION); - } - return locker; - }) - .toList(); + // 구역부터 확인한다. 없는 구역이면 같은 경로의 구역 상세 조회와 같이 404로 나가야 한다 + if (lockerRepository.findSectionById(sectionId).isEmpty()) { + throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND); + } + + // 구역 전체가 아니라 요청한 사물함만 읽는다. 36칸 구역에서 두 칸만 바꿔도 전체를 읽지 않도록 한다 + List lockers = lockerRepository.findLockersByIds(lockerIds); + boolean allInSection = lockers.size() == lockerIds.size() + && lockers.stream().allMatch(locker -> locker.getSectionId().equals(sectionId)); + if (!allInSection) { + // 없는 사물함과 다른 구역 사물함을 가르지 않는다. 운영 화면에서는 둘 다 "잘못 고른 칸"이다 + throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION); + } + return lockers; } /** 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 66c31e3b2..f8e218d24 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 @@ -365,6 +365,11 @@ public void saveLockers(List lockers) { throw new UnsupportedOperationException(); } + @Override + public List findLockersByIds(Collection lockerIds) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { batchLookups++; diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 91d37bfee..0103ffdd5 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -328,6 +328,11 @@ public void saveLockers(List lockers) { throw new UnsupportedOperationException(); } + @Override + public List findLockersByIds(Collection lockerIds) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java index bdcb4433c..a98b81209 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java @@ -17,6 +17,8 @@ public interface LockerJpaRepository extends JpaRepository findAllByIdIn(Collection ids); + List findAllByIdInAndIsDeletedFalse(Collection ids); + /** * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로 * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java index 460d54056..073c7c215 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -145,6 +145,16 @@ public void saveLockers(List lockers) { .toList()); } + @Override + public List findLockersByIds(Collection lockerIds) { + if (lockerIds.isEmpty()) { + return List.of(); + } + return lockerJpaRepository.findAllByIdInAndIsDeletedFalse(lockerIds).stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { return lockerJpaRepository.findAllByIdIn(lockerIds).stream() From d20a6ad8a4beb7c4272d1302d76efd8ad4666e08 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 27 Oct 2026 21:31:54 +0900 Subject: [PATCH 25/34] =?UTF-8?q?docs:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=B4=20=EB=AA=BB=20?= =?UTF-8?q?=EB=A7=89=EB=8A=94=20=EA=B2=BD=ED=95=A9=EA=B3=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=A0=95=20=EC=A7=91=EA=B3=84=20=EA=B7=BC=EA=B1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/admin/event/locker/AdminLockerSectionApi.java | 4 +++- .../event/domain/locker/service/impl/LockerServiceImpl.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java index 5f6bd29aa..4b2388f1f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java @@ -56,7 +56,9 @@ ApiResponse getLockerSection( + "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, " + "AVAILABLE로 되돌리는 것은 막지 않는다. " + "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다. " - + "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다.") + + "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다. " + + "이용 중 검사와 학생 신청이 겹치면 서로 다른 테이블에 써서 둘 다 성공할 수 있고, " + + "그때는 배정된 칸이 사용 중지된 채로 남는다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"}) ApiResponse changeLockerStatus( diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index e0dcdbab5..d47246abd 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -89,7 +89,8 @@ private LockerSectionSummary summarize( int availableCount = (int) lockers.stream() .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) .count(); - // 사용 중지된 사물함도 배정돼 있으면 센다. 배정 후 고장 난 칸이라 운영진이 다른 자리를 줘야 하는 대상이다 + // 사용 중지된 사물함도 배정돼 있으면 센다. 상태 변경 API가 이용 중인 칸의 사용 중지를 막아 그 조합은 흔치 + // 않지만, DB 직접 수정이나 검사와 신청이 겹친 경우로 남을 수 있다 int assignedCount = (int) lockers.stream() .filter(locker -> appliedLockerIds.contains(locker.getId())) .count(); From bc008a40a654c948b740f91ab3e1effa71a8bdbf Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 01:38:15 +0900 Subject: [PATCH 26/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=EC=97=90=20=EC=B7=A8=EC=86=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EC=99=80=20=ED=99=9C=EC=84=B1=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/event/LockerApplicationJpaEntity.java | 12 +++---- ...add_is_canceled_to_locker_applications.sql | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java index 378a603c1..262c5b1f4 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java @@ -14,19 +14,15 @@ import lombok.NoArgsConstructor; @Entity +// uk_locker_applications_active_member_key·uk_locker_applications_active_locker_key(V26)는 generated column +// active_member_key·active_locker_key에 걸려 있다. Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table( name = "locker_applications", indexes = { - @Index( - name = "uk_locker_applications_locker_period_id_locker_id", - columnList = "locker_period_id, locker_id", - unique = true - ), @Index(name = "idx_locker_applications_member_id_applied_at", columnList = "member_id, applied_at"), @Index( - name = "uk_locker_applications_locker_period_id_member_id", - columnList = "locker_period_id, member_id", - unique = true + name = "idx_locker_applications_locker_period_id_locker_id_is_canceled", + columnList = "locker_period_id, locker_id, is_canceled" ) } ) diff --git a/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql b/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql new file mode 100644 index 000000000..f84f94475 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql @@ -0,0 +1,34 @@ +-- 운영진이 신청을 취소할 수 있게 한다. 취소해도 행은 남긴다 — 운영진이 그 칸이 왜 비었는지 알아야 하고, +-- 지난 신청 이력도 남아야 한다. +-- +-- 승인 절차 없이 신청이 곧 배정이라 값은 "살아 있음/취소됨" 둘뿐이다. 그래서 status 문자열이 아니라 불리언으로 둔다. +-- "기간종료"는 저장하지 않고 운영 회차로 계산한다(LockerApplicationStatus). +-- +-- ⚠️ 유니크를 갈아끼우는 동안(DROP 직후 CREATE 사이) 같은 칸 신청이 두 건 들어오면 둘 다 커밋되고 +-- 그 뒤 인덱스 생성이 실패한다. 신청 기간 밖에 적용한다. +-- 적용 전 SELECT COUNT(*) FROM locker_applications 로 행 수를 확인한다. +ALTER TABLE locker_applications + ADD COLUMN is_canceled TINYINT(1) NOT NULL DEFAULT 0; + +-- 취소된 신청이 재배정을 막지 않도록 살아 있는 신청에만 유니크를 건다. +-- UNIQUE(col, is_canceled)로는 막을 수 없다 — 취소 행끼리의 중복이 그대로 허용된다. (flyway-migration.md 3-4절) +ALTER TABLE locker_applications + ADD COLUMN active_member_key VARCHAR(64) + GENERATED ALWAYS AS (IF(is_canceled = 0, CONCAT(locker_period_id, '-', member_id), NULL)) VIRTUAL, + ADD COLUMN active_locker_key VARCHAR(64) + GENERATED ALWAYS AS (IF(is_canceled = 0, CONCAT(locker_period_id, '-', locker_id), NULL)) VIRTUAL; + +DROP INDEX uk_locker_applications_locker_period_id_member_id ON locker_applications; +DROP INDEX uk_locker_applications_locker_period_id_locker_id ON locker_applications; + +CREATE UNIQUE INDEX uk_locker_applications_active_member_key + ON locker_applications (active_member_key); +CREATE UNIQUE INDEX uk_locker_applications_active_locker_key + ON locker_applications (active_locker_key); + +-- 유니크를 generated column으로 옮기면서 (locker_period_id, locker_id) 접두 조회가 인덱스를 잃는다. +-- generated column 유니크는 CONCAT 문자열에 걸려 있어 이 조건으로는 탈 수 없다. +-- 이용 중 판정과 신청 여부 조회가 이 경로를 쓰고, 둘 다 취소된 신청을 빼야 하므로 is_canceled를 뒤에 단다(3-4절). +-- (V11이 반대 방향으로 했던 교체를 되돌리는 셈이다) +CREATE INDEX idx_locker_applications_locker_period_id_locker_id_is_canceled + ON locker_applications (locker_period_id, locker_id, is_canceled); From 503e569862d5ad896b58eeecd7e589cb75c1ba85 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 15:09:51 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=B7=A8=EC=86=8C=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=99=EC=9E=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/domain/LockerApplication.java | 21 ++++++++++++++++--- .../domain/locker/domain/LockerErrorCode.java | 4 +++- .../LockerApplicationServiceImplTest.java | 5 +++-- .../db/event/LockerApplicationJpaEntity.java | 6 +++++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java index d5dbaf34a..c9e3f1caf 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -16,15 +17,18 @@ public class LockerApplication { private Long memberId; private Long lockerId; private LocalDateTime appliedAt; + /** 운영진이 취소했는지. 취소해도 행은 남기므로 살아 있는 신청인지는 이 값으로 가른다. */ + private boolean canceled; public static LockerApplication of( Long id, Long lockerPeriodId, Long memberId, Long lockerId, - LocalDateTime appliedAt + LocalDateTime appliedAt, + boolean canceled ) { - return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt); + return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt, canceled); } /** @@ -33,6 +37,17 @@ public static LockerApplication of( * @param appliedAt 신청 시각. 클라이언트가 보내지 않고 서버 시각으로 기록한다 */ public static LockerApplication create(Long lockerPeriodId, Long memberId, Long lockerId, LocalDateTime appliedAt) { - return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt); + return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt, false); + } + + /** + * 운영진이 신청을 취소한다. 행은 남기고 표시만 바꾼다 — 운영진이 그 칸이 왜 비었는지 알아야 하고, + * 같은 학생·같은 칸에 다시 배정할 수 있어야 한다. + */ + public void cancel() { + if (canceled) { + throw new BusinessException(LockerErrorCode.LOCKER_APPLICATION_ALREADY_CANCELED); + } + this.canceled = true; } } 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 83be320f2..b479568c1 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 @@ -22,7 +22,9 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."), LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."), LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."), - LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."); + LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."), + LOCKER_APPLICATION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 신청을 찾을 수 없습니다."), + LOCKER_APPLICATION_ALREADY_CANCELED(ErrorStatus.CONFLICT, "이미 취소된 신청입니다."); private final int status; private final String message; 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 f8e218d24..b1b0653d3 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 @@ -200,7 +200,7 @@ private LockerPeriod period(Long id, String name, LocalDate usageStart, LocalDat } private LockerApplication application(Long id, Long periodId, Long lockerId, LocalDateTime appliedAt) { - return LockerApplication.of(id, periodId, MEMBER_ID, lockerId, appliedAt); + return LockerApplication.of(id, periodId, MEMBER_ID, lockerId, appliedAt, false); } private List applicationIds(List results) { @@ -464,7 +464,8 @@ public LockerApplication save(LockerApplication application) { application.getLockerPeriodId(), application.getMemberId(), application.getLockerId(), - application.getAppliedAt() + application.getAppliedAt(), + application.isCanceled() ); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java index 262c5b1f4..f63af8480 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java @@ -46,12 +46,16 @@ public class LockerApplicationJpaEntity extends BaseCreatedTimeEntity { @Column(name = "applied_at", nullable = false) private LocalDateTime appliedAt; + @Column(name = "is_canceled", nullable = false) + private boolean isCanceled; + private LockerApplicationJpaEntity(LockerApplication application) { this.id = application.getId(); this.lockerPeriodId = application.getLockerPeriodId(); this.memberId = application.getMemberId(); this.lockerId = application.getLockerId(); this.appliedAt = application.getAppliedAt(); + this.isCanceled = application.isCanceled(); } public static LockerApplicationJpaEntity from(LockerApplication application) { @@ -59,6 +63,6 @@ public static LockerApplicationJpaEntity from(LockerApplication application) { } public LockerApplication toDomain() { - return LockerApplication.of(id, lockerPeriodId, memberId, lockerId, appliedAt); + return LockerApplication.of(id, lockerPeriodId, memberId, lockerId, appliedAt, isCanceled); } } From 56cdbf70def8e34dda979e467afc41ccac9d1fcf Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 15:17:19 +0900 Subject: [PATCH 28/34] =?UTF-8?q?fix:=20=EC=B7=A8=EC=86=8C=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=8B=A0=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/event/locker/AppLockerApi.java | 2 +- .../LockerApplicationRepository.java | 4 ++-- .../domain/locker/service/LockerService.java | 2 +- .../impl/LockerApplicationServiceImpl.java | 4 ++-- .../event/LockerApplicationJpaRepository.java | 20 +++++++++++++------ .../LockerApplicationRepositoryImpl.java | 6 +++--- 6 files changed, 23 insertions(+), 15 deletions(-) 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 9229ce98d..d3a6f8c3f 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 @@ -54,7 +54,7 @@ ApiResponse getSectionLockers( /** 고른 사물함을 신청하고 즉시 배정한다. */ @Operation(summary = "사물함 신청", description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 모집 기간 안에서만 받는다. " - + "운영 회차당 한 개만 신청할 수 있고 신청 후에는 취소하거나 변경할 수 없다. " + + "운영 회차당 한 개만 신청할 수 있고 신청 후에는 직접 취소하거나 변경할 수 없다. " + "같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java index 0b72feb2f..2bdfd9b3e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -27,12 +27,12 @@ public interface LockerApplicationRepository { Set findAppliedLockerIds(Long lockerPeriodId); /** - * 해당 운영 회차에 신청한 회원 식별자. 취소가 없어 신청 행이 곧 사물함을 가진 회원이다. + * 해당 운영 회차에 사물함을 가진 회원 식별자. 취소된 신청은 뺀다. */ List findMemberIds(Long lockerPeriodId); /** - * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + * 해당 운영 회차에서 회원이 쓰고 있는 사물함 식별자. 취소되지 않은 신청은 회차당 한 건까지라 최대 하나다. */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index 6ca368a33..03a802256 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -38,7 +38,7 @@ public interface LockerService { Map> getLockerMapBySectionId(); /** - * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + * 해당 운영 회차에서 회원이 쓰고 있는 사물함. 취소되지 않은 신청은 회차당 한 건까지라 최대 하나다. *

* 회차 게시 여부와 노출 범위는 확인하지 않는다. 호출하는 쪽이 판단한다. */ 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 796b66196..59821916f 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 @@ -45,8 +45,8 @@ public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) } /** - * 트랜잭션을 걸지 않는다. 신청은 취소·변경이 없고, 조회 사이에 회차 게시·이름이 바뀌어도 각 건은 그 시점에 맞는 - * 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다. + * 트랜잭션을 걸지 않는다. 조회 사이에 회차 게시·이름이 바뀌거나 운영진이 신청을 취소해도 각 건은 그 시점에 + * 맞는 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다. */ @Override public List getApplicationsByMemberId(Long memberId) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index ecc0552fb..bb204cc5b 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -8,37 +8,45 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * 취소된 신청은 자리를 비켜준 신청이라 대부분의 조회에서 뺀다. 남겨 두면 취소한 칸을 다시 배정할 수 없다. + * 예외는 {@link #existsByLockerPeriodId}와 운영진 신청 현황 조회다. + */ public interface LockerApplicationJpaRepository extends JpaRepository { + /** + * 취소된 신청도 센다. 취소 행도 회차를 가리키므로, 회차를 지우면 가리킬 대상이 없어진다. + */ boolean existsByLockerPeriodId(Long lockerPeriodId); - boolean existsByLockerPeriodIdAndLockerId(Long lockerPeriodId, Long lockerId); + boolean existsByLockerPeriodIdAndLockerIdAndIsCanceledFalse(Long lockerPeriodId, Long lockerId); - List findAllByMemberIdOrderByAppliedAtDescIdDesc(Long memberId); + List findAllByMemberIdAndIsCanceledFalseOrderByAppliedAtDescIdDesc(Long memberId); /** * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다. */ @Query(""" SELECT a.lockerId FROM LockerApplicationJpaEntity a - WHERE a.lockerPeriodId = :lockerPeriodId + WHERE a.lockerPeriodId = :lockerPeriodId AND a.isCanceled = false """) Set findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); @Query(""" SELECT a.memberId FROM LockerApplicationJpaEntity a - WHERE a.lockerPeriodId = :lockerPeriodId + WHERE a.lockerPeriodId = :lockerPeriodId AND a.isCanceled = false """) List findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); - boolean existsByLockerPeriodIdInAndLockerIdIn(Collection lockerPeriodIds, Collection lockerIds); + boolean existsByLockerPeriodIdInAndLockerIdInAndIsCanceledFalse( + Collection lockerPeriodIds, Collection lockerIds); /** * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. */ @Query(""" SELECT a.lockerId FROM LockerApplicationJpaEntity a - WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId + WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId AND a.isCanceled = false """) Optional findLockerIdByLockerPeriodIdAndMemberId( @Param("lockerPeriodId") Long lockerPeriodId, diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index 374fc52a5..1345a02f0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -28,7 +28,7 @@ public boolean existsByPeriod(Long lockerPeriodId) { @Override public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { - return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId); + return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerIdAndIsCanceledFalse(lockerPeriodId, lockerId); } @Override @@ -51,12 +51,12 @@ public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, C if (lockerPeriodIds.isEmpty() || lockerIds.isEmpty()) { return false; } - return lockerApplicationJpaRepository.existsByLockerPeriodIdInAndLockerIdIn(lockerPeriodIds, lockerIds); + return lockerApplicationJpaRepository.existsByLockerPeriodIdInAndLockerIdInAndIsCanceledFalse(lockerPeriodIds, lockerIds); } @Override public List findByMemberId(Long memberId) { - return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream() + return lockerApplicationJpaRepository.findAllByMemberIdAndIsCanceledFalseOrderByAppliedAtDescIdDesc(memberId).stream() .map(LockerApplicationJpaEntity::toDomain) .toList(); } From a96d34ae80434d135120b3781090dab9631a5fb1 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 15:42:28 +0900 Subject: [PATCH 29/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/locker/domain/LockerErrorCode.java | 1 + .../LockerApplicationRepository.java | 14 ++++++++ .../LockerApplicationServiceImplTest.java | 12 +++++++ .../service/impl/LockerServiceImplTest.java | 12 +++++++ .../event/LockerApplicationJpaRepository.java | 23 ++++++++++++ .../LockerApplicationRepositoryImpl.java | 36 ++++++++++++++++++- 6 files changed, 97 insertions(+), 1 deletion(-) 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 b479568c1..95bae6a73 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 @@ -23,6 +23,7 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."), LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."), LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."), + MEMBER_ALREADY_HAS_LOCKER(ErrorStatus.CONFLICT, "이미 이 회차에 사물함을 배정받은 학생입니다."), LOCKER_APPLICATION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 신청을 찾을 수 없습니다."), LOCKER_APPLICATION_ALREADY_CANCELED(ErrorStatus.CONFLICT, "이미 취소된 신청입니다."); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java index 2bdfd9b3e..f5b6d4b0c 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; /** @@ -46,6 +48,18 @@ public interface LockerApplicationRepository { */ List findByMemberId(Long memberId); + /** + * 신청 한 건. 취소된 신청도 돌려준다 — 운영진이 취소 여부를 보고 판단한다. + */ + Optional findById(Long lockerApplicationId); + + /** + * 운영 회차의 신청을 신청 일시 최신순으로 조회한다. 운영진 화면이 쓰므로 취소된 신청도 담는다. + * + * @param memberIds 회원으로 좁힐 때만 넘긴다. {@code null}이면 회차 전체를 본다 + */ + PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset); + /** * 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면 * {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다. 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 b1b0653d3..9cbd4921e 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 @@ -16,6 +16,8 @@ import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; @@ -469,6 +471,16 @@ public LockerApplication save(LockerApplication application) { ); } + @Override + public Optional findById(Long lockerApplicationId) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) { + throw new UnsupportedOperationException(); + } + @Override public List findByMemberId(Long memberId) { return memberApplications.stream().filter(value -> value.getMemberId().equals(memberId)).toList(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 0103ffdd5..de252ec1a 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -12,6 +12,8 @@ import java.util.Set; import java.util.stream.Stream; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; @@ -381,6 +383,16 @@ public LockerApplication save(LockerApplication application) { throw new UnsupportedOperationException(); } + @Override + public Optional findById(Long lockerApplicationId) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) { + throw new UnsupportedOperationException(); + } + @Override public List findByMemberId(Long memberId) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index bb204cc5b..d27cc2954 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -41,6 +43,27 @@ public interface LockerApplicationJpaRepository extends JpaRepository lockerPeriodIds, Collection lockerIds); + /** + * 운영진 신청 현황. 취소된 신청도 담아 운영진이 그 칸이 왜 비었는지 알 수 있게 한다. + */ + @Query(value = """ + SELECT a FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId + AND (:filterByMember = false OR a.memberId IN :memberIds) + ORDER BY a.appliedAt DESC, a.id DESC + """, + countQuery = """ + SELECT COUNT(a) FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId + AND (:filterByMember = false OR a.memberId IN :memberIds) + """) + Page search( + @Param("lockerPeriodId") Long lockerPeriodId, + @Param("filterByMember") boolean filterByMember, + @Param("memberIds") List memberIds, + Pageable pageable + ); + /** * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. */ diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index 1345a02f0..42a428d68 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -5,19 +5,26 @@ import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class LockerApplicationRepositoryImpl implements LockerApplicationRepository { - private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_locker_period_id_locker_id"; + // V26에서 유니크 둘을 generated column(active_locker_key·active_member_key)으로 옮겼다. 취소된 신청이 자리를 + // 잡지 않도록 한 것이라 제약 이름도 함께 바뀐다 + private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_active_locker_key"; + private static final String MEMBER_UNIQUE_CONSTRAINT = "uk_locker_applications_active_member_key"; private final LockerApplicationJpaRepository lockerApplicationJpaRepository; @@ -61,6 +68,29 @@ public List findByMemberId(Long memberId) { .toList(); } + @Override + public Optional findById(Long lockerApplicationId) { + return lockerApplicationJpaRepository.findById(lockerApplicationId) + .map(LockerApplicationJpaEntity::toDomain); + } + + @Override + public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) { + boolean filterByMember = memberIds != null; + List ids = filterByMember ? memberIds : List.of(); + + Page result = lockerApplicationJpaRepository.search( + lockerPeriodId, filterByMember, ids, PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(LockerApplicationJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public LockerApplication save(LockerApplication application) { try { @@ -71,6 +101,10 @@ public LockerApplication save(LockerApplication application) { if (ConstraintViolationUtil.isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); } + // 회원 쪽 제약도 여기서 잡는다. 안 잡으면 운영진이 이미 칸을 가진 학생에게 배정할 때 500이 나간다 + if (ConstraintViolationUtil.isViolated(e, MEMBER_UNIQUE_CONSTRAINT)) { + throw new BusinessException(LockerErrorCode.MEMBER_ALREADY_HAS_LOCKER); + } throw e; } } From e1949999a9654e00aa22ea3bac568f3978f61cf0 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 15:47:14 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=98=84=ED=99=A9=C2=B7=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=C2=B7=EC=88=98=EB=8F=99=20=EB=B0=B0=EC=A0=95=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/service/AdminLockerService.java | 25 +++++++ .../service/impl/AdminLockerServiceImpl.java | 65 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java index 2cd2da49e..38dad81b9 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java @@ -1,6 +1,9 @@ package kr.ac.kookmin.stream.event.domain.locker.service; import java.util.List; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand; @@ -45,4 +48,26 @@ public interface AdminLockerService { * 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다. */ void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status); + + /** + * 운영 회차의 신청 현황을 신청 일시 최신순으로 조회한다. 취소된 신청도 담는다. + * + * @param memberIds 회원으로 좁힐 때만 넘긴다. {@code null}이면 회차 전체를 본다 + */ + PageResult getApplications(Long lockerPeriodId, List memberIds, PageOffset pageOffset); + + /** + * 신청을 취소한다. 행은 남기고 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있게 한다. + *

+ * 사용 기간이 끝난 회차의 신청은 되돌릴 자리가 없어 거부한다. + */ + void cancelApplication(Long lockerApplicationId); + + /** + * 운영진이 학생에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓친 학생이나 자리를 옮겨야 하는 + * 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. 사용 기간이 끝난 회차만 막는다. + *

+ * 학생회비 납부자인지는 이 서비스가 보지 않는다. 교차 도메인이라 {@code api:admin-api}의 UseCase가 확인한다. + */ + LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, Long lockerId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index ffcbeace8..af300fee7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -3,11 +3,16 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; @@ -85,6 +90,66 @@ public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatu lockerRepository.saveLockers(lockers); } + @Override + public PageResult getApplications( + Long lockerPeriodId, List memberIds, PageOffset pageOffset + ) { + LockerPeriod period = getPeriod(lockerPeriodId); + PageResult applications = + lockerApplicationRepository.search(lockerPeriodId, memberIds, pageOffset); + + List lockerIds = applications.content().stream() + .map(LockerApplication::getLockerId) + .distinct() + .toList(); + // 철거된 사물함도 지난 신청의 이름을 보여줘야 하므로 삭제된 사물함까지 읽는다 + Map lockers = lockerIds.isEmpty() + ? Map.of() + : lockerRepository.findLockersByIdsIncludingDeleted(lockerIds).stream() + .collect(Collectors.toMap(Locker::getId, Function.identity())); + + return applications.map( + application -> new LockerApplicationResult(application, lockers.get(application.getLockerId()), period)); + } + + @Override + @Transactional + public void cancelApplication(Long lockerApplicationId) { + LockerApplication application = lockerApplicationRepository.findById(lockerApplicationId) + .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_APPLICATION_NOT_FOUND)); + requireUsageNotEnded(application.getLockerPeriodId()); + + application.cancel(); + lockerApplicationRepository.save(application); + } + + @Override + @Transactional + public LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, Long lockerId) { + LockerPeriod period = requireUsageNotEnded(lockerPeriodId); + Locker locker = lockerRepository.findLockerById(lockerId) + .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION)); + if (!locker.isSelectable(lockerApplicationRepository.existsByLocker(lockerPeriodId, lockerId))) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + + // 회원이 이미 가진 칸이 있으면 저장 시 유니크 제약이 막는다 + LockerApplication saved = lockerApplicationRepository.save( + LockerApplication.create(lockerPeriodId, memberId, lockerId, LocalDateTime.now())); + return new LockerApplicationResult(saved, locker, period); + } + + /** + * 사용 기간이 끝난 회차는 신청을 손댈 수 없다. 취소해도 되돌릴 자리가 없고, 배정해도 쓸 수 없다. + */ + private LockerPeriod requireUsageNotEnded(Long lockerPeriodId) { + LockerPeriod period = getPeriod(lockerPeriodId); + if (period.isUsageEnded(LocalDate.now())) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_ALREADY_ENDED); + } + return period; + } + /** * 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다. */ From 00e90f38c476cc43a0483a518991645e951684b3 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 15:51:55 +0900 Subject: [PATCH 31/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EB=B0=B0=EC=A0=95=C2=B7=EB=B0=B0=EC=A0=95?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/LockerAssignedByAdmin.java | 4 ++++ .../common/event/LockerAssignmentCanceled.java | 4 ++++ .../service/impl/AdminLockerServiceImpl.java | 6 ++++++ .../notification/domain/NotificationType.java | 4 ++++ .../service/impl/NotificationEventListener.java | 15 +++++++++++++++ 5 files changed, 33 insertions(+) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java new file mode 100644 index 000000000..eeffdbb0a --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.common.event; + +/** 운영진이 회원에게 사물함을 직접 배정했다. 회원이 신청한 적 없는 배정이라 {@link LockerAssigned}와 문구가 다르다. */ +public record LockerAssignedByAdmin(Long memberId, Long lockerPeriodId) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java new file mode 100644 index 000000000..b9b5f2673 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.common.event; + +/** 운영진이 회원의 사물함 배정을 취소했다. 회원 화면에서는 신청이 사라지므로 알림이 유일한 전달 수단이다. */ +public record LockerAssignmentCanceled(Long memberId, Long lockerPeriodId) implements DomainEvent {} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index af300fee7..d502d6635 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -8,8 +8,11 @@ import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.OutboxWriter; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.common.event.LockerAssignedByAdmin; +import kr.ac.kookmin.stream.common.event.LockerAssignmentCanceled; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; @@ -33,6 +36,7 @@ class AdminLockerServiceImpl implements AdminLockerService { private final LockerRepository lockerRepository; private final LockerApplicationRepository lockerApplicationRepository; + private final OutboxWriter outboxWriter; @Override public List getAllPeriods() { @@ -121,6 +125,7 @@ public void cancelApplication(Long lockerApplicationId) { application.cancel(); lockerApplicationRepository.save(application); + outboxWriter.record(new LockerAssignmentCanceled(application.getMemberId(), application.getLockerPeriodId())); } @Override @@ -136,6 +141,7 @@ public LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, // 회원이 이미 가진 칸이 있으면 저장 시 유니크 제약이 막는다 LockerApplication saved = lockerApplicationRepository.save( LockerApplication.create(lockerPeriodId, memberId, lockerId, LocalDateTime.now())); + outboxWriter.record(new LockerAssignedByAdmin(memberId, lockerPeriodId)); return new LockerApplicationResult(saved, locker, period); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java index 7c16e36a0..e10348c60 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java @@ -24,6 +24,10 @@ public enum NotificationType { // 값: 없음 LOCKER_ASSIGNED(NotificationCategory.LOCKER, "사물함을 신청했어요.", "배정된 사물함 번호를 확인해 주세요."), + LOCKER_ASSIGNED_BY_ADMIN(NotificationCategory.LOCKER, + "운영진이 사물함을 배정했어요.", "배정된 사물함 번호를 확인해 주세요."), + LOCKER_ASSIGNMENT_CANCELED(NotificationCategory.LOCKER, + "사물함 배정이 취소됐어요.", "신청 기간이 남아 있으면 다시 신청할 수 있어요."), LOCKER_APPLY_CLOSING(NotificationCategory.LOCKER, "사물함 신청이 내일 마감돼요.", "이용하려면 기간 내에 신청해 주세요."), LOCKER_USAGE_ENDING(NotificationCategory.LOCKER, "사물함 이용 기간이 얼마 남지 않았어요.", "만료 전에 개인 물품을 미리 정리해 주세요."), diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java index 6a8fea674..c08a6f72e 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java @@ -7,6 +7,8 @@ import kr.ac.kookmin.stream.common.event.EventStartingTomorrow; import kr.ac.kookmin.stream.common.event.LockerApplyClosingTomorrow; import kr.ac.kookmin.stream.common.event.LockerAssigned; +import kr.ac.kookmin.stream.common.event.LockerAssignedByAdmin; +import kr.ac.kookmin.stream.common.event.LockerAssignmentCanceled; import kr.ac.kookmin.stream.common.event.LockerUsageEndingSoon; import kr.ac.kookmin.stream.common.event.NoticeCreated; import kr.ac.kookmin.stream.common.event.RentalDueSoon; @@ -45,6 +47,19 @@ public void on(LockerAssigned event) { NotificationType.LOCKER_ASSIGNED, List.of(), event.lockerPeriodId(), List.of(event.memberId())); } + @EventListener + public void on(LockerAssignedByAdmin event) { + notificationDispatcher.dispatch( + NotificationType.LOCKER_ASSIGNED_BY_ADMIN, List.of(), event.lockerPeriodId(), List.of(event.memberId())); + } + + // 본인이 하지 않은 변경이고 화면에서는 신청이 사라지므로, 취소는 알림으로만 알 수 있다 + @EventListener + public void on(LockerAssignmentCanceled event) { + notificationDispatcher.dispatch( + NotificationType.LOCKER_ASSIGNMENT_CANCELED, List.of(), event.lockerPeriodId(), List.of(event.memberId())); + } + // 공지는 활성 회원 전체에게 간다 @EventListener public void on(NoticeCreated event) { From 3e56620d3c8ce54f7699e0844eb8e6937e0d673f Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 16:36:55 +0900 Subject: [PATCH 32/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/AdminLockerApplicationApi.java | 37 ++++++++++++++ .../AdminLockerApplicationController.java | 41 +++++++++++++++ .../locker/request/LockerPeriodParams.java | 3 +- .../AdminLockerApplicationSearchResponse.java | 39 +++++++++++++++ .../AdminLockerApplicationSearchUseCase.java | 50 +++++++++++++++++++ .../usecase/MemberLockerApplication.java | 10 ++++ 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java new file mode 100644 index 000000000..4d7f3c499 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 운영진 사물함 신청 API의 문서 명세. 구현은 {@link AdminLockerApplicationController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + */ +@Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회") +public interface AdminLockerApplicationApi { + + /** 운영 회차의 신청 현황. */ + @Operation(summary = "사물함 신청 현황 조회", + description = "운영 회차의 신청을 신청 일시 최신순으로 조회한다. 이름·학번 일부를 keyword로 넘기면 그 회원만 본다. " + + "운영진이 취소한 신청도 담기며 canceled로 구분한다. 학생 화면에서는 취소된 신청이 보이지 않는다. " + + "탈퇴한 회원의 지난 신청은 name·studentId가 null이고, 철거된 사물함은 lockerLabel이 null이다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse> getApplications( + AdminApiUser apiUser, + @ParameterObject LockerPeriodParams params, + String keyword, + @ParameterObject PageParams pageParams + ); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java new file mode 100644 index 000000000..3a3ec21e3 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerApplicationSearchUseCase; +import kr.ac.kookmin.stream.api.admin.event.locker.usecase.MemberLockerApplication; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.common.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/lockers/applications") +@RequiredArgsConstructor +public class AdminLockerApplicationController implements AdminLockerApplicationApi { + + private final AdminLockerApplicationSearchUseCase adminLockerApplicationSearchUseCase; + + @Override + @GetMapping + public ApiResponse> getApplications( + AdminApiUser apiUser, + @Valid @ModelAttribute LockerPeriodParams params, + @RequestParam(required = false) String keyword, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = adminLockerApplicationSearchUseCase.search( + params.lockerPeriodId(), keyword, pageParams.toOffset()); + + return ApiResponse.success(PageResponse.from(result, vo -> AdminLockerApplicationSearchResponse.of( + vo.result().application(), vo.result().locker(), vo.member()))); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java index 34e1a8b0a..a1d400383 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java @@ -3,7 +3,8 @@ import jakarta.validation.constraints.NotNull; /** - * 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다. + * 운영진 조회의 기준 회차. 구역 조회와 신청 현황 조회가 함께 쓴다. 구역은 운영 회차에 묶여 있지 않고, + * 회차는 "어느 회차의 배정 현황을 볼지"를 정한다. *

* 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가 * {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고, diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java new file mode 100644 index 000000000..e527f4ef0 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +/** + * 운영진 신청 현황의 한 줄. + * + * @param name 신청자 이름. 탈퇴한 회원의 지난 신청이면 {@code null} + * @param studentId 신청자 학번. 탈퇴한 회원의 지난 신청이면 {@code null} + * @param lockerLabel 배정된 사물함 이름. 철거된 사물함도 이름은 보여준다 + * @param canceled 운영진이 취소했는지. 기간이 끝났는지는 회차의 사용 종료일로 화면이 가른다 + */ +public record AdminLockerApplicationSearchResponse( + Long lockerApplicationId, + Long memberId, + String name, + String studentId, + String lockerLabel, + LocalDateTime appliedAt, + boolean canceled +) { + + public static AdminLockerApplicationSearchResponse of( + LockerApplication application, Locker locker, Member member + ) { + return new AdminLockerApplicationSearchResponse( + application.getId(), + application.getMemberId(), + member == null ? null : member.getName(), + member == null ? null : member.getStudentId(), + locker == null ? null : locker.getLockerLabel(), + application.getAppliedAt(), + application.isCanceled() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java new file mode 100644 index 000000000..7d0703847 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java @@ -0,0 +1,50 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.usecase; + +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; +import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminLockerApplicationSearchUseCase { + + private final AdminLockerService adminLockerService; + private final MemberService memberService; + + /** + * 운영 회차의 신청 현황에 신청자 정보를 붙인다. 취소된 신청도 담는다. + * + * @param keyword 이름·학번 일부. 비어 있으면 회차 전체를 본다 + */ + public PageResult search(Long lockerPeriodId, String keyword, PageOffset pageOffset) { + List memberIds = null; + + if (keyword != null && !keyword.isBlank()) { + memberIds = memberService.searchIdsByKeyword(keyword); + + if (memberIds.isEmpty()) { + return PageResult.empty(pageOffset.page(), pageOffset.size()); + } + } + + PageResult result = + adminLockerService.getApplications(lockerPeriodId, memberIds, pageOffset); + List resultMemberIds = result.content().stream() + .map(applicationResult -> applicationResult.application().getMemberId()) + .toList(); + + // 탈퇴(소프트 삭제)한 회원은 getMapByIds에서 빠지므로, 그 회원의 지난 신청은 member가 null로 남는다 + Map memberMap = memberService.getMapByIds(resultMemberIds); + + return result.map(applicationResult -> new MemberLockerApplication( + applicationResult, memberMap.getOrDefault(applicationResult.application().getMemberId(), null))); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java new file mode 100644 index 000000000..1bfde3a62 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.usecase; + +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +// AdminLockerApplicationSearchUseCase가 조합한 결과. DTO 변환은 Controller가 한다 +// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함). +// member는 탈퇴(소프트 삭제)한 회원의 지난 신청이면 null일 수 있다. +public record MemberLockerApplication(LockerApplicationResult result, Member member) { +} From dba10fdf7c876c9bbd5e05ddc2a4da34fb1a249b Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 28 Oct 2026 16:45:50 +0900 Subject: [PATCH 33/34] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=B7=A8=EC=86=8C=C2=B7=EC=88=98=EB=8F=99?= =?UTF-8?q?=20=EB=B0=B0=EC=A0=95=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../locker/AdminLockerApplicationApi.java | 27 ++++++++++++++++- .../AdminLockerApplicationController.java | 23 +++++++++++++++ .../locker/request/LockerAssignRequest.java | 22 ++++++++++++++ .../usecase/AdminLockerAssignUseCase.java | 29 +++++++++++++++++++ .../locker/service/AdminLockerService.java | 2 +- .../service/impl/AdminLockerServiceImpl.java | 19 ++++++------ 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java index 4d7f3c499..b7ce5f93c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerAssignRequest; import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; @@ -11,6 +12,7 @@ import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import org.springdoc.core.annotations.ParameterObject; /** @@ -18,7 +20,7 @@ *

* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. */ -@Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회") +@Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회·취소·수동 배정") public interface AdminLockerApplicationApi { /** 운영 회차의 신청 현황. */ @@ -34,4 +36,27 @@ ApiResponse> getApplications( String keyword, @ParameterObject PageParams pageParams ); + + /** 운영진이 신청을 취소한다. */ + @Operation(summary = "사물함 신청 취소", + description = "운영진이 신청을 취소한다. 행은 남기고 취소 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있다. " + + "학생 화면에서는 그 신청이 사라져 미신청으로 보이고, 취소됐다는 사실은 알림으로 전달한다. " + + "사용 기간이 끝난 회차의 신청은 되돌릴 자리가 없어 거부한다.") + @ApiErrorCode(type = LockerErrorCode.class, + codes = {"LOCKER_APPLICATION_NOT_FOUND", "LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED", + "LOCKER_APPLICATION_ALREADY_CANCELED"}) + ApiResponse cancelApplication(AdminApiUser apiUser, Long lockerApplicationId); + + /** 운영진이 학생에게 사물함을 직접 배정한다. */ + @Operation(summary = "사물함 수동 배정", + description = "운영진이 학생회비 납부자에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓쳤거나 " + + "자리를 옮겨야 하는 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. " + + "사용 기간이 끝난 회차와 이미 신청된 칸은 거부하고, 한 학생이 한 회차에 두 칸을 가질 수 없다. " + + "응답 본문이 없으므로 결과는 신청 현황을 다시 조회해 받는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"}) + @ApiErrorCode(type = LockerErrorCode.class, + codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED", "LOCKER_NOT_IN_SECTION", + "LOCKER_ALREADY_ASSIGNED", "MEMBER_ALREADY_HAS_LOCKER"}) + ApiResponse assignLocker(AdminApiUser apiUser, LockerAssignRequest request); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java index 3a3ec21e3..0174ae143 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java @@ -2,17 +2,24 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerAssignRequest; import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse; import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerApplicationSearchUseCase; +import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerAssignUseCase; import kr.ac.kookmin.stream.api.admin.event.locker.usecase.MemberLockerApplication; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -23,6 +30,8 @@ public class AdminLockerApplicationController implements AdminLockerApplicationApi { private final AdminLockerApplicationSearchUseCase adminLockerApplicationSearchUseCase; + private final AdminLockerAssignUseCase adminLockerAssignUseCase; + private final AdminLockerService adminLockerService; @Override @GetMapping @@ -38,4 +47,18 @@ public ApiResponse> getApplic return ApiResponse.success(PageResponse.from(result, vo -> AdminLockerApplicationSearchResponse.of( vo.result().application(), vo.result().locker(), vo.member()))); } + + @Override + @DeleteMapping("/{lockerApplicationId}") + public ApiResponse cancelApplication(AdminApiUser apiUser, @PathVariable Long lockerApplicationId) { + adminLockerService.cancelApplication(lockerApplicationId); + return ApiResponse.success(); + } + + @Override + @PostMapping + public ApiResponse assignLocker(AdminApiUser apiUser, @Valid @RequestBody LockerAssignRequest request) { + adminLockerAssignUseCase.assign(request.lockerPeriodId(), request.memberId(), request.lockerId()); + return ApiResponse.success(); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java new file mode 100644 index 000000000..874faa2da --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.NotNull; + +/** + * 운영진이 학생에게 사물함을 직접 배정한다. + *

+ * 회원은 식별자로 받는다. 운영진 화면이 학생회비 납부자 목록을 이름·학번으로 검색해 고르는 구조라, 거기서 받은 + * 식별자를 그대로 넘기면 된다. + */ +public record LockerAssignRequest( + + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId, + + @NotNull(message = "배정할 학생을 선택해 주세요.") + Long memberId, + + @NotNull(message = "배정할 사물함을 선택해 주세요.") + Long lockerId +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java new file mode 100644 index 000000000..9c514d468 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.usecase; + +import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService; +import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminLockerAssignUseCase { + + private final PayerService payerService; + private final AdminLockerService adminLockerService; + + /** + * 학생회비 납부자인지 확인하고 사물함을 배정한다. 운영진 화면이 납부자 목록에서 학생을 고르게 돼 있어 같은 기준을 + * 서버에서도 지킨다. + *

+ * 납부 여부는 회비 도메인이, 배정은 사물함 도메인이 안다. 도메인끼리 부르지 않고 여기서 묶는다 + * ({@code architecture.md} 6-1절). + *

+ * 트랜잭션을 걸지 않는다. 납부자 확인은 읽기이고 그 전에 쓰는 것이 없어, 실패해도 되돌릴 것이 없다. + * 쓰기는 {@code assignLocker}가 자기 트랜잭션에서 한다. + */ + public void assign(Long lockerPeriodId, Long memberId, Long lockerId) { + payerService.validatePayer(memberId); + adminLockerService.assignLocker(lockerPeriodId, memberId, lockerId); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java index 38dad81b9..b188e33df 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java @@ -69,5 +69,5 @@ public interface AdminLockerService { *

* 학생회비 납부자인지는 이 서비스가 보지 않는다. 교차 도메인이라 {@code api:admin-api}의 UseCase가 확인한다. */ - LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, Long lockerId); + void assignLocker(Long lockerPeriodId, Long memberId, Long lockerId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java index d502d6635..1eced3d8e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java @@ -121,7 +121,7 @@ public PageResult getApplications( public void cancelApplication(Long lockerApplicationId) { LockerApplication application = lockerApplicationRepository.findById(lockerApplicationId) .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_APPLICATION_NOT_FOUND)); - requireUsageNotEnded(application.getLockerPeriodId()); + validateUsageNotEnded(application.getLockerPeriodId(), LocalDate.now()); application.cancel(); lockerApplicationRepository.save(application); @@ -130,8 +130,11 @@ public void cancelApplication(Long lockerApplicationId) { @Override @Transactional - public LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, Long lockerId) { - LockerPeriod period = requireUsageNotEnded(lockerPeriodId); + public void assignLocker(Long lockerPeriodId, Long memberId, Long lockerId) { + // 사용 기간 판정과 신청 시각이 같은 시계를 보게 한 번만 읽는다. 따로 읽으면 자정을 걸칠 때 기간 안으로 + // 통과한 신청이 다음 날짜로 기록된다 + LocalDateTime now = LocalDateTime.now(); + validateUsageNotEnded(lockerPeriodId, now.toLocalDate()); Locker locker = lockerRepository.findLockerById(lockerId) .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION)); if (!locker.isSelectable(lockerApplicationRepository.existsByLocker(lockerPeriodId, lockerId))) { @@ -139,21 +142,17 @@ public LockerApplicationResult assignLocker(Long lockerPeriodId, Long memberId, } // 회원이 이미 가진 칸이 있으면 저장 시 유니크 제약이 막는다 - LockerApplication saved = lockerApplicationRepository.save( - LockerApplication.create(lockerPeriodId, memberId, lockerId, LocalDateTime.now())); + lockerApplicationRepository.save(LockerApplication.create(lockerPeriodId, memberId, lockerId, now)); outboxWriter.record(new LockerAssignedByAdmin(memberId, lockerPeriodId)); - return new LockerApplicationResult(saved, locker, period); } /** * 사용 기간이 끝난 회차는 신청을 손댈 수 없다. 취소해도 되돌릴 자리가 없고, 배정해도 쓸 수 없다. */ - private LockerPeriod requireUsageNotEnded(Long lockerPeriodId) { - LockerPeriod period = getPeriod(lockerPeriodId); - if (period.isUsageEnded(LocalDate.now())) { + private void validateUsageNotEnded(Long lockerPeriodId, LocalDate today) { + if (getPeriod(lockerPeriodId).isUsageEnded(today)) { throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_ALREADY_ENDED); } - return period; } /** From d13e9078bbe6ff684981131c4d85c2609db31bb9 Mon Sep 17 00:00:00 2001 From: sangrae Date: Thu, 29 Oct 2026 17:43:04 +0900 Subject: [PATCH 34/34] =?UTF-8?q?refactor:=20=EC=9A=94=EC=95=BD=EA=B3=BC?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=98=EB=8A=94=20=EC=8B=A0=EC=B2=AD=20API=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/event/locker/AdminLockerApplicationApi.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java index b7ce5f93c..410776d25 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java @@ -23,7 +23,7 @@ @Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회·취소·수동 배정") public interface AdminLockerApplicationApi { - /** 운영 회차의 신청 현황. */ + /** 신청자 이름·학번과 배정된 칸. 취소된 신청도 담는다. */ @Operation(summary = "사물함 신청 현황 조회", description = "운영 회차의 신청을 신청 일시 최신순으로 조회한다. 이름·학번 일부를 keyword로 넘기면 그 회원만 본다. " + "운영진이 취소한 신청도 담기며 canceled로 구분한다. 학생 화면에서는 취소된 신청이 보이지 않는다. " @@ -37,7 +37,6 @@ ApiResponse> getApplications( @ParameterObject PageParams pageParams ); - /** 운영진이 신청을 취소한다. */ @Operation(summary = "사물함 신청 취소", description = "운영진이 신청을 취소한다. 행은 남기고 취소 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있다. " + "학생 화면에서는 그 신청이 사라져 미신청으로 보이고, 취소됐다는 사실은 알림으로 전달한다. " @@ -47,7 +46,6 @@ ApiResponse> getApplications( "LOCKER_APPLICATION_ALREADY_CANCELED"}) ApiResponse cancelApplication(AdminApiUser apiUser, Long lockerApplicationId); - /** 운영진이 학생에게 사물함을 직접 배정한다. */ @Operation(summary = "사물함 수동 배정", description = "운영진이 학생회비 납부자에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓쳤거나 " + "자리를 옮겨야 하는 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. "