From bf232f2e105e1d38200686c6a9d6dc16302d8559 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 16 Oct 2026 17:52:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=9A=8C=EC=B0=A8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=C2=B7=EC=88=98=EC=A0=95=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=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 --- .../domain/locker/domain/LockerErrorCode.java | 7 ++ .../domain/locker/domain/LockerPeriod.java | 111 +++++++++++++++++- .../domain/LockerPeriodCreateCommand.java | 16 +++ .../locker/domain/LockerPeriodStatus.java | 13 ++ .../locker/domain/LockerPeriodSummary.java | 13 ++ .../domain/LockerPeriodUpdateCommand.java | 14 +++ .../domain/LockerApplicationStatusTest.java | 2 + 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodCreateCommand.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodStatus.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodSummary.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodUpdateCommand.java 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 ab9b3e49..ca2a4b18 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 @@ -13,6 +13,13 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."), LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."), + INVALID_LOCKER_PERIOD_SEMESTER(ErrorStatus.BAD_REQUEST, "학기는 1 또는 2여야 합니다."), + INVALID_LOCKER_PERIOD_RANGE(ErrorStatus.BAD_REQUEST, "신청 기간과 사용 기간의 순서가 올바르지 않습니다."), + LOCKER_PERIOD_SEMESTER_DUPLICATED(ErrorStatus.CONFLICT, "이미 등록된 학기입니다."), + LOCKER_PERIOD_OVERLAPPED(ErrorStatus.CONFLICT, "다른 회차와 기간이 겹칩니다."), + LOCKER_PERIOD_ALREADY_ENDED(ErrorStatus.CONFLICT, "종료된 회차는 수정할 수 없습니다."), + LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."), + LOCKER_PERIOD_HAS_APPLICATIONS(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 4c2b2304..bf1506ac 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 @@ -2,6 +2,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -12,7 +14,13 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class LockerPeriod { + private static final int FIRST_SEMESTER = 1; + private static final int SECOND_SEMESTER = 2; + private Long id; + private int year; + private int semester; + /** 연도와 학기로 만든 표시용 이름. 예: 2026-1학기 */ private String name; private LocalDateTime applyStartAt; private LocalDateTime applyEndAt; @@ -23,6 +31,8 @@ public class LockerPeriod { public static LockerPeriod of( Long id, + int year, + int semester, String name, LocalDateTime applyStartAt, LocalDateTime applyEndAt, @@ -30,7 +40,66 @@ public static LockerPeriod of( LocalDate usageEndAt, boolean published ) { - return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published); + return new LockerPeriod(id, year, semester, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published); + } + + /** + * 운영진이 새 회차를 만든다. 이름은 연도와 학기로 서버가 만든다. + *

+ * 학생에게 회차를 보여주는 경로는 홈 화면 배너뿐이고 그쪽에서 따로 설정하므로, 게시 여부는 요청으로 받지 않고 바로 게시 상태로 둔다. + * 미게시로 두면 구역 조회와 신청이 모두 회차를 찾지 못한다. + */ + public static LockerPeriod create(LockerPeriodCreateCommand command) { + validateSemester(command.semester()); + validateDates(command.applyStartDate(), command.applyEndDate(), command.usageStartDate(), command.usageEndDate()); + + return new LockerPeriod( + null, + command.year(), + command.semester(), + toName(command.year(), command.semester()), + command.applyStartDate().atStartOfDay(), + command.applyEndDate().atTime(LocalTime.MAX.withNano(0)), + command.usageStartDate(), + command.usageEndDate(), + true + ); + } + + /** + * 신청·사용 기간을 바꾼다. 연도·학기와 게시 여부는 이 경로로 바뀌지 않는다. + * + * @param now 회차 상태를 판단할 기준 시각 + */ + public void update(LockerPeriodUpdateCommand command, LocalDateTime now) { + validateDates(command.applyStartDate(), command.applyEndDate(), command.usageStartDate(), command.usageEndDate()); + validateChangeable(command.applyStartDate(), now); + + this.applyStartAt = command.applyStartDate().atStartOfDay(); + this.applyEndAt = command.applyEndDate().atTime(LocalTime.MAX.withNano(0)); + this.usageStartAt = command.usageStartDate(); + this.usageEndAt = command.usageEndDate(); + } + + /** + * 신청 시작 전이면 예정, 사용 종료일이 지났으면 종료, 그 사이는 진행 중이다. + */ + public LockerPeriodStatus calculateStatus(LocalDateTime now) { + if (now.isBefore(applyStartAt)) { + return LockerPeriodStatus.UPCOMING; + } + if (isUsageEnded(now.toLocalDate())) { + return LockerPeriodStatus.ENDED; + } + return LockerPeriodStatus.IN_PROGRESS; + } + + /** + * 회차 범위(신청 시작일 ~ 사용 종료일)가 다른 회차와 겹치는지. 회차는 같은 시기에 둘이 돌 수 없다. + */ + public boolean overlaps(LockerPeriod other) { + return !applyStartAt.toLocalDate().isAfter(other.usageEndAt) + && !other.applyStartAt.toLocalDate().isAfter(usageEndAt); } /** @@ -39,4 +108,44 @@ public static LockerPeriod of( public boolean isUsageEnded(LocalDate today) { return today.isAfter(usageEndAt); } + + private static String toName(int year, int semester) { + return "%d-%d학기".formatted(year, semester); + } + + private static void validateSemester(int semester) { + if (semester != FIRST_SEMESTER && semester != SECOND_SEMESTER) { + throw new BusinessException(LockerErrorCode.INVALID_LOCKER_PERIOD_SEMESTER); + } + } + + /** + * 신청 마감일과 사용 시작일의 선후는 보지 않는다. 사용을 시작한 뒤 남은 자리를 추가 모집하려고 마감일을 늘리는 + * 운영이 있어 두 기간이 겹칠 수 있다. + */ + private static void validateDates( + LocalDate applyStartDate, + LocalDate applyEndDate, + LocalDate usageStartDate, + LocalDate usageEndDate + ) { + if (applyStartDate.isAfter(applyEndDate) || usageStartDate.isAfter(usageEndDate)) { + throw new BusinessException(LockerErrorCode.INVALID_LOCKER_PERIOD_RANGE); + } + } + + /** + * 종료된 회차는 손대지 않는다. 진행 중인 회차는 이미 학생이 신청을 시작했으므로 신청 시작일을 되돌릴 수 없다. + *

+ * 수정이 전체 교체라 바뀌지 않은 시작일도 원래 값 그대로 다시 올라온다. 그래서 "보냈는가"가 아니라 "원래와 다른가"를 본다. + */ + private void validateChangeable(LocalDate applyStartDate, LocalDateTime now) { + LockerPeriodStatus status = calculateStatus(now); + if (status == LockerPeriodStatus.ENDED) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_ALREADY_ENDED); + } + if (status == LockerPeriodStatus.IN_PROGRESS && !applyStartDate.equals(applyStartAt.toLocalDate())) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_APPLY_ALREADY_STARTED); + } + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodCreateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodCreateCommand.java new file mode 100644 index 00000000..3360e132 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodCreateCommand.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.time.LocalDate; + +/** + * 날짜만 받는다. 신청 기간의 시·분·초는 도메인이 정한다(시작일 00:00:00 ~ 마감일 23:59:59). + */ +public record LockerPeriodCreateCommand( + int year, + int semester, + LocalDate applyStartDate, + LocalDate applyEndDate, + LocalDate usageStartDate, + LocalDate usageEndDate +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodStatus.java new file mode 100644 index 00000000..f3b4da24 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodStatus.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 운영 회차의 진행 상태. 저장하지 않고 서버 현재 시각으로 그때그때 계산한다. + */ +public enum LockerPeriodStatus { + /** 신청 시작 전 */ + UPCOMING, + /** 신청 시작 이후 사용 종료일까지 */ + IN_PROGRESS, + /** 사용 종료일 이후 */ + ENDED +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodSummary.java new file mode 100644 index 00000000..43b73ab2 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodSummary.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.time.LocalDateTime; + +/** + * 운영 회차와 그 시점의 진행 상태. 상태는 저장하지 않고 조회할 때 계산하므로 회차와 따로 들고 다닌다. + */ +public record LockerPeriodSummary(LockerPeriod period, LockerPeriodStatus status) { + + public static LockerPeriodSummary of(LockerPeriod period, LocalDateTime now) { + return new LockerPeriodSummary(period, period.calculateStatus(now)); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodUpdateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodUpdateCommand.java new file mode 100644 index 00000000..229c48e5 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriodUpdateCommand.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.time.LocalDate; + +/** + * 연도와 학기는 바꿀 수 없어 담지 않는다. 날짜만 받는 이유는 {@link LockerPeriodCreateCommand}와 같다. + */ +public record LockerPeriodUpdateCommand( + LocalDate applyStartDate, + LocalDate applyEndDate, + LocalDate usageStartDate, + LocalDate usageEndDate +) { +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java index a102eaf7..28cbc79b 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java @@ -12,6 +12,8 @@ class LockerApplicationStatusTest { private static final LockerPeriod PERIOD = LockerPeriod.of( 1L, + 2026, + 2, "2026-2학기", LocalDateTime.of(2026, 8, 20, 10, 0), LocalDateTime.of(2026, 8, 25, 18, 0), From 1ddeb97c9a79a6e4541965af7f2177352cd0a31e Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 16 Oct 2026 19:04:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=9A=8C=EC=B0=A8=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LockerApplicationRepository.java | 5 ++ .../locker/repository/LockerRepository.java | 21 ++++++++ .../LockerApplicationServiceImplTest.java | 31 +++++++++-- .../service/impl/LockerServiceImplTest.java | 27 +++++++++- .../event/LockerApplicationJpaRepository.java | 2 + .../LockerApplicationRepositoryImpl.java | 5 ++ .../db/event/LockerPeriodJpaEntity.java | 18 +++++-- .../db/event/LockerPeriodJpaRepository.java | 10 ++-- .../stream/db/event/LockerRepositoryImpl.java | 51 +++++++++++++++++-- ...ester_and_is_deleted_to_locker_periods.sql | 26 ++++++++++ 10 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V20__add_semester_and_is_deleted_to_locker_periods.sql 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 2817b5e2..5acd2fbd 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 @@ -10,6 +10,11 @@ */ public interface LockerApplicationRepository { + /** + * 해당 운영 회차에 신청이 한 건이라도 있는지. 신청이 딸린 회차는 지울 수 없다. + */ + boolean existsByPeriod(Long lockerPeriodId); + /** * 해당 운영 회차에 이 사물함이 이미 신청되었는지. */ 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 928f5e48..a2f5083e 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 @@ -13,6 +13,27 @@ */ public interface LockerRepository { + /** + * 운영 회차 전체를 신청 시작 일시 최신순으로 조회한다. 운영진 화면용이라 아직 게시하지 않은 회차도 포함한다. + */ + List findAllPeriods(); + + /** + * 운영 회차 한 건. 운영진 화면용이라 아직 게시하지 않은 회차도 포함한다. + */ + Optional findPeriodById(Long lockerPeriodId); + + /** + * 운영 회차를 저장한다. 식별자가 없으면 새로 만들고, 있으면 그 회차를 바꾼다. + */ + LockerPeriod savePeriod(LockerPeriod period); + + /** + * 운영 회차를 소프트 삭제한다. 이미 삭제됐거나 없는 회차면 false를 돌려준다. + * 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다. + */ + boolean deletePeriod(Long lockerPeriodId); + /** * 게시된 운영 회차가 있는지. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 한다. */ 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 9a0cd7d9..ba7a1246 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 @@ -44,6 +44,8 @@ class LockerApplicationServiceImplTest { private static LockerPeriod period() { return LockerPeriod.of( PERIOD_ID, + 2026, + 2, "2026-2학기", LocalDateTime.of(2026, 8, 20, 10, 0), LocalDateTime.of(2026, 8, 25, 18, 0), @@ -192,7 +194,7 @@ class GetApplicationsByMemberId { private LockerPeriod period(Long id, String name, LocalDate usageStart, LocalDate usageEnd, boolean published) { LocalDateTime applyAt = usageStart.atStartOfDay(); - return LockerPeriod.of(id, name, applyAt, applyAt, usageStart, usageEnd, published); + return LockerPeriod.of(id, 2026, 2, name, applyAt, applyAt, usageStart, usageEnd, published); } private LockerApplication application(Long id, Long periodId, Long lockerId, LocalDateTime appliedAt) { @@ -281,7 +283,6 @@ void emptyWithoutApplications() { * 신청 저장·조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. */ private static final class FakeLockerRepository implements LockerRepository { - private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); private LockerPeriod period; private Locker locker; @@ -354,6 +355,26 @@ public List findLockersByIdsIncludingDeleted(Collection lockerIds) // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 + @Override + public List findAllPeriods() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPeriodById(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + + @Override + public LockerPeriod savePeriod(LockerPeriod period) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean deletePeriod(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsPublishedPeriod(Long lockerPeriodId) { throw new UnsupportedOperationException(); @@ -386,7 +407,6 @@ public List findLockersBySectionId(Long sectionId) { } private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { - static final Long SAVED_ID = 25L; private final Set> appliedPeriodLockerIds = new HashSet<>(); @@ -394,6 +414,11 @@ private static final class FakeLockerApplicationRepository implements LockerAppl private final List saved = new ArrayList<>(); private List memberApplications = List.of(); + @Override + public boolean existsByPeriod(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { return appliedPeriodLockerIds.contains(List.of(lockerPeriodId, lockerId)); 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 bc7c28f3..57bce53a 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 @@ -192,7 +192,6 @@ void emptyWhenLockerRemoved() { * 신청 조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. */ private static final class FakeLockerRepository implements LockerRepository { - private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); private boolean publishedPeriod = true; @@ -241,6 +240,26 @@ FakeLockerRepository withMissingSection() { return this; } + @Override + public List findAllPeriods() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPeriodById(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + + @Override + public LockerPeriod savePeriod(LockerPeriod period) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean deletePeriod(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsPublishedPeriod(Long lockerPeriodId) { return publishedPeriod; @@ -298,7 +317,6 @@ public List findLockersByIdsIncludingDeleted(Collection lockerIds) } private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { - private Set appliedLockerIds = Set.of(); private Long myLockerId; @@ -314,6 +332,11 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + @Override + public boolean existsByPeriod(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { 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 66a51e51..b3619273 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 @@ -9,6 +9,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository { + boolean existsByLockerPeriodId(Long lockerPeriodId); + boolean existsByLockerPeriodIdAndLockerId(Long lockerPeriodId, Long lockerId); List findAllByMemberIdOrderByAppliedAtDescIdDesc(Long memberId); 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 5363247b..d5d7a9c7 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 @@ -20,6 +20,11 @@ public class LockerApplicationRepositoryImpl implements LockerApplicationReposit private final LockerApplicationJpaRepository lockerApplicationJpaRepository; + @Override + public boolean existsByPeriod(Long lockerPeriodId) { + return lockerApplicationJpaRepository.existsByLockerPeriodId(lockerPeriodId); + } + @Override public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java index afcac785..3f8b8754 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java @@ -8,21 +8,30 @@ import jakarta.persistence.Table; import java.time.LocalDate; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import lombok.AccessLevel; import lombok.NoArgsConstructor; @Entity +// uk_locker_periods_active_semester(V20)는 generated column active_semester에 걸려 있다. +// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table(name = "locker_periods") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class LockerPeriodJpaEntity extends BaseTimeEntity { +public class LockerPeriodJpaEntity extends BaseSoftDeleteEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "locker_period_id") private Long id; + // YEAR는 MySQL 데이터 타입 이름이라 컬럼명이 period_year다 + @Column(name = "period_year", nullable = false, columnDefinition = "SMALLINT") + private int periodYear; + + @Column(name = "semester", nullable = false, columnDefinition = "TINYINT") + private int semester; + @Column(nullable = false, length = 100) private String name; @@ -43,6 +52,8 @@ public class LockerPeriodJpaEntity extends BaseTimeEntity { private LockerPeriodJpaEntity(LockerPeriod period) { this.id = period.getId(); + this.periodYear = period.getYear(); + this.semester = period.getSemester(); this.name = period.getName(); this.applyStartAt = period.getApplyStartAt(); this.applyEndAt = period.getApplyEndAt(); @@ -56,6 +67,7 @@ public static LockerPeriodJpaEntity from(LockerPeriod period) { } public LockerPeriod toDomain() { - return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, isPublished); + return LockerPeriod.of( + id, periodYear, semester, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, isPublished); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java index aa76e596..51fbb091 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java @@ -7,9 +7,13 @@ public interface LockerPeriodJpaRepository extends JpaRepository { - boolean existsByIdAndIsPublishedTrue(Long id); + List findAllByIsDeletedFalseOrderByApplyStartAtDesc(); - Optional findByIdAndIsPublishedTrue(Long id); + Optional findByIdAndIsDeletedFalse(Long id); - List findAllByIdInAndIsPublishedTrue(Collection ids); + boolean existsByIdAndIsPublishedTrueAndIsDeletedFalse(Long id); + + Optional findByIdAndIsPublishedTrueAndIsDeletedFalse(Long id); + + List findAllByIdInAndIsPublishedTrueAndIsDeletedFalse(Collection ids); } 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 949b3805..42af32fa 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 @@ -3,37 +3,82 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +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.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class LockerRepositoryImpl implements LockerRepository { + private static final String SEMESTER_UNIQUE_CONSTRAINT = "uk_locker_periods_active_semester"; + private final LockerPeriodJpaRepository lockerPeriodJpaRepository; private final LockerSectionJpaRepository lockerSectionJpaRepository; private final LockerSectionLayoutJpaRepository lockerSectionLayoutJpaRepository; private final LockerJpaRepository lockerJpaRepository; + @Override + public List findAllPeriods() { + return lockerPeriodJpaRepository.findAllByIsDeletedFalseOrderByApplyStartAtDesc().stream() + .map(LockerPeriodJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findPeriodById(Long lockerPeriodId) { + return lockerPeriodJpaRepository.findByIdAndIsDeletedFalse(lockerPeriodId) + .map(LockerPeriodJpaEntity::toDomain); + } + + @Override + public LockerPeriod savePeriod(LockerPeriod period) { + try { + // 같은 학기를 동시에 등록하면 유니크 제약에 걸린다. 제약 위반을 이 자리에서 잡으려면 커밋 시점까지 미루지 않고 + // 바로 INSERT를 내보내야 한다 + return lockerPeriodJpaRepository.saveAndFlush(LockerPeriodJpaEntity.from(period)).toDomain(); + } catch (DataIntegrityViolationException e) { + if (ConstraintViolationUtil.isViolated(e, SEMESTER_UNIQUE_CONSTRAINT)) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_SEMESTER_DUPLICATED); + } + throw e; + } + } + + // 조회한 엔티티를 바로 바꿔 더티 체킹에 맡긴다. 존재 확인과 삭제가 한 쿼리로 묶이고, save(merge) 경로를 타지 않아 + // 도메인 객체가 삭제 상태를 들고 있을 필요가 없다 + @Override + public boolean deletePeriod(Long lockerPeriodId) { + return lockerPeriodJpaRepository.findByIdAndIsDeletedFalse(lockerPeriodId) + .map(entity -> { + entity.delete(); + return true; + }) + .orElse(false); + } + @Override public boolean existsPublishedPeriod(Long lockerPeriodId) { - return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrue(lockerPeriodId); + return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrueAndIsDeletedFalse(lockerPeriodId); } @Override public Optional findPublishedPeriodById(Long lockerPeriodId) { - return lockerPeriodJpaRepository.findByIdAndIsPublishedTrue(lockerPeriodId) + return lockerPeriodJpaRepository.findByIdAndIsPublishedTrueAndIsDeletedFalse(lockerPeriodId) .map(LockerPeriodJpaEntity::toDomain); } @Override public List findPublishedPeriodsByIds(Collection lockerPeriodIds) { - return lockerPeriodJpaRepository.findAllByIdInAndIsPublishedTrue(lockerPeriodIds).stream() + return lockerPeriodJpaRepository.findAllByIdInAndIsPublishedTrueAndIsDeletedFalse(lockerPeriodIds).stream() .map(LockerPeriodJpaEntity::toDomain) .toList(); } diff --git a/infrastructure/db/src/main/resources/db/migration/V20__add_semester_and_is_deleted_to_locker_periods.sql b/infrastructure/db/src/main/resources/db/migration/V20__add_semester_and_is_deleted_to_locker_periods.sql new file mode 100644 index 00000000..cfd3bf7d --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V20__add_semester_and_is_deleted_to_locker_periods.sql @@ -0,0 +1,26 @@ +-- 관리자 API가 회차를 등록·삭제할 수 있게 한다. +-- +-- 연도·학기: 등록 화면이 연도와 학기를 받고 이름(예: 2026-1학기)은 서버가 만든다. 상세 조회에서 둘을 따로 내려줘야 해서 +-- 이름에서 파싱하지 않고 컬럼으로 둔다. 운영 중인 행이 있어 DEFAULT를 주고(3-3절) 아래에서 이름으로 채운다. +-- YEAR는 MySQL 데이터 타입 이름이라 컬럼명을 period_year로 둔다. +-- 삭제: 실수로 지운 회차를 되살릴 수 있도록 행은 남긴다. 회차는 한 학기에 한두 건이라 인덱스는 두지 않는다. +ALTER TABLE locker_periods + ADD COLUMN period_year SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN semester TINYINT NOT NULL DEFAULT 0, + ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0; + +-- 이 컬럼이 생기기 전에 DB로 직접 넣은 회차(V15 주석 참고)는 이름이 "2026-2학기" 형식이다. 거기서 채운다. +-- +-- 형식이 다른 행은 0으로 남는다. 수정 API가 연도·학기를 바꾸지 않으므로 그런 행은 DB에서 직접 고쳐야 한다. +-- 그리고 0으로 남는 행이 둘 이상이면 아래 유니크 인덱스가 중복('0-0')으로 실패해 이 마이그레이션 전체가 멈춘다. +-- 적용 전에 SELECT locker_period_id, name FROM locker_periods; 로 행 수와 이름 형식을 확인한다. +UPDATE locker_periods +SET period_year = CAST(SUBSTRING_INDEX(name, '-', 1) AS UNSIGNED), + semester = CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(name, '-', -1), '학기', 1) AS UNSIGNED) +WHERE name REGEXP '^[0-9]{4}-[12]학기$'; + +-- 같은 학기 회차는 하나만 둔다. 삭제한 회차가 학기를 점유하지 않도록 활성 행에만 건다 (flyway-migration.md 3-4절). +ALTER TABLE locker_periods + ADD COLUMN active_semester VARCHAR(10) + GENERATED ALWAYS AS (IF(is_deleted = 0, CONCAT(period_year, '-', semester), NULL)) VIRTUAL; +CREATE UNIQUE INDEX uk_locker_periods_active_semester ON locker_periods (active_semester); From cb773b521c0cfd35815552f2251762643f7abfb5 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 16 Oct 2026 19:04:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=9A=8C=EC=B0=A8=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=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 --- .../domain/locker/service/LockerService.java | 30 +++++++++ .../service/impl/LockerServiceImpl.java | 67 +++++++++++++++++++ 2 files changed, 97 insertions(+) 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 6ca368a3..f5b0f690 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 @@ -5,11 +5,41 @@ import java.util.Optional; import java.util.Set; 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.LockerSectionDetail; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; public interface LockerService { + /** + * 운영 회차 전체를 신청 시작 일시 최신순으로 조회한다. 아직 게시하지 않은 회차도 포함하므로 운영진 경로에서만 쓴다. + */ + List getAllPeriods(); + + /** + * 운영 회차 한 건. 아직 게시하지 않은 회차도 포함하므로 운영진 경로에서만 쓴다. + */ + LockerPeriodSummary getPeriodById(Long lockerPeriodId); + + /** + * 운영 회차를 새로 만든다. 같은 학기가 이미 있거나 다른 회차와 기간이 겹치면 거부한다. + * 만들어진 회차는 바로 게시 상태가 된다. + */ + void createPeriod(LockerPeriodCreateCommand command); + + /** + * 운영 회차의 신청·사용 기간을 바꾼다. 종료된 회차는 바꿀 수 없고, 진행 중인 회차는 신청 시작일을 바꿀 수 없다. + * 다른 회차와 기간이 겹쳐도 거부한다. + */ + void updatePeriod(Long lockerPeriodId, LockerPeriodUpdateCommand command); + + /** + * 운영 회차를 소프트 삭제한다. 신청이 한 건이라도 있으면 거부한다. + */ + void deletePeriod(Long lockerPeriodId); + /** * 운영 회차의 구역별 전체/선택 가능 사물함 수와 표시 상태를 조회한다. */ 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 02c82ecf..16e7ef5a 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.service.impl; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -8,6 +9,10 @@ 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.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.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; @@ -26,6 +31,50 @@ class LockerServiceImpl implements LockerService { private final LockerRepository lockerRepository; private final LockerApplicationRepository lockerApplicationRepository; + @Override + public List getAllPeriods() { + LocalDateTime now = LocalDateTime.now(); + return lockerRepository.findAllPeriods().stream() + .map(period -> LockerPeriodSummary.of(period, now)) + .toList(); + } + + @Override + public LockerPeriodSummary getPeriodById(Long lockerPeriodId) { + return LockerPeriodSummary.of(getPeriod(lockerPeriodId), LocalDateTime.now()); + } + + @Override + @Transactional + public void createPeriod(LockerPeriodCreateCommand command) { + LockerPeriod period = LockerPeriod.create(command); + validateNotOverlapped(period); + + lockerRepository.savePeriod(period); + } + + @Override + @Transactional + public void updatePeriod(Long lockerPeriodId, LockerPeriodUpdateCommand command) { + LockerPeriod period = getPeriod(lockerPeriodId); + period.update(command, LocalDateTime.now()); + validateNotOverlapped(period); + + lockerRepository.savePeriod(period); + } + + @Override + @Transactional + public void deletePeriod(Long lockerPeriodId) { + // 신청이 딸린 회차를 지우면 신청이 가리킬 회차가 없어진다. FK가 없어 DB가 막아주지 않는다 + if (lockerApplicationRepository.existsByPeriod(lockerPeriodId)) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_HAS_APPLICATIONS); + } + if (!lockerRepository.deletePeriod(lockerPeriodId)) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND); + } + } + @Override @Transactional(readOnly = true) public List getSections(Long lockerPeriodId) { @@ -99,6 +148,24 @@ private LockerSectionSummary summarize( ); } + private LockerPeriod getPeriod(Long lockerPeriodId) { + return lockerRepository.findPeriodById(lockerPeriodId) + .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); + } + + /** + * 회차는 같은 시기에 둘이 돌 수 없다. 회차 수가 한 학기에 한두 건이라 전부 읽어 메모리에서 견준다. + * 자기 자신은 식별자로 걸러낸다(등록이면 식별자가 없어 전부 비교 대상이다). + */ + private void validateNotOverlapped(LockerPeriod period) { + boolean overlapped = lockerRepository.findAllPeriods().stream() + .filter(other -> !other.getId().equals(period.getId())) + .anyMatch(period::overlaps); + if (overlapped) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_OVERLAPPED); + } + } + /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */ private void requirePublishedPeriod(Long lockerPeriodId) { if (!lockerRepository.existsPublishedPeriod(lockerPeriodId)) { From 4935fd8cac0868d1bd6df0e512e9ea020c213e2b Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 16 Oct 2026 19:04:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=9A=8C=EC=B0=A8=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=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 --- api/admin-api/build.gradle.kts | 1 + .../event/locker/AdminLockerPeriodApi.java | 66 +++++++++++++++++ .../locker/AdminLockerPeriodController.java | 71 +++++++++++++++++++ .../request/LockerPeriodCreateRequest.java | 43 +++++++++++ .../request/LockerPeriodUpdateRequest.java | 29 ++++++++ .../AdminLockerPeriodDetailResponse.java | 38 ++++++++++ .../AdminLockerPeriodListItemResponse.java | 31 ++++++++ .../AdminLockerPeriodListResponse.java | 28 ++++++++ 8 files changed, 307 insertions(+) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodCreateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodUpdateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListItemResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListResponse.java diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index 983679fe..3d452814 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:event")) implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodApi.java new file mode 100644 index 00000000..8a4d978a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodApi.java @@ -0,0 +1,66 @@ +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.LockerPeriodCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodUpdateRequest; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodListResponse; +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; + +/** + * 운영진 사물함 운영 회차 API의 문서 명세. 구현은 {@link AdminLockerPeriodController}가 맡는다. + *

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

+ * 회차 상태(`periodStatus`)는 저장하지 않고 서버 현재 시각으로 계산한다 — 신청 시작 전이면 {@code UPCOMING}, + * 사용 종료일이 지났으면 {@code ENDED}, 그 사이는 {@code IN_PROGRESS}다. + */ +@Tag(name = "사물함 회차 관리", description = "운영진 사물함 운영 회차 조회·등록·수정·삭제") +public interface AdminLockerPeriodApi { + + /** 회차 목록과 진행 중인 회차. 학생 앱 조회와 달리 아직 게시하지 않은 회차도 함께 보여준다. */ + @Operation(summary = "사물함 운영 회차 목록 조회", + description = "운영 회차를 신청 시작 일시 최신순으로 모두 조회한다. 진행 중인 회차는 `currentPeriod`에도 따로 담기며 " + + "없으면 null이다. 삭제한 회차는 빠진다.") + ApiResponse getLockerPeriods(AdminApiUser apiUser); + + /** 회차 상세. 수정 화면의 기존 값을 채우는 데 쓴다. */ + @Operation(summary = "사물함 운영 회차 상세 조회", + description = "운영 회차 한 건을 조회한다. 연도·학기는 수정할 수 없지만 화면 표시를 위해 따로 내려준다. " + + "삭제한 회차는 찾을 수 없는 것으로 응답한다.") + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse getLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId); + + /** 회차 등록. 등록한 회차는 바로 학생 앱에서 신청할 수 있는 상태가 된다. */ + @Operation(summary = "사물함 운영 회차 등록", + description = "운영 회차를 등록한다. 회차 이름은 연도와 학기로 서버가 만든다(예: 2027-1학기). 신청 기간은 " + + "시작일 00:00:00부터 마감일 23:59:59까지로 저장한다. 같은 학기는 하나만 등록할 수 있고, 회차 기간" + + "(신청 시작일 ~ 사용 종료일)은 다른 회차와 겹칠 수 없다. " + + "신청 마감일과 사용 시작일의 선후는 보지 않는다 — 사용을 시작한 뒤 남은 자리를 추가 모집할 수 있다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, + codes = {"INVALID_LOCKER_PERIOD_RANGE", "LOCKER_PERIOD_SEMESTER_DUPLICATED", "LOCKER_PERIOD_OVERLAPPED"}) + ApiResponse createLockerPeriod(AdminApiUser apiUser, LockerPeriodCreateRequest request); + + /** 회차 수정. 전체 교체이므로 바꾸지 않는 값도 원래 값 그대로 보낸다. */ + @Operation(summary = "사물함 운영 회차 수정", + description = "운영 회차의 신청·사용 기간을 통째로 바꾼다. 연도·학기와 게시 여부는 바뀌지 않는다. " + + "종료된 회차는 수정할 수 없고, 진행 중인 회차는 신청 시작일을 다른 값으로 바꿀 수 없다(원래 값을 그대로 보내면 된다). " + + "바꾼 기간이 다른 회차와 겹쳐도 거부한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, + codes = {"LOCKER_PERIOD_NOT_FOUND", "INVALID_LOCKER_PERIOD_RANGE", "LOCKER_PERIOD_ALREADY_ENDED", + "LOCKER_PERIOD_APPLY_ALREADY_STARTED", "LOCKER_PERIOD_OVERLAPPED"}) + ApiResponse updateLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId, LockerPeriodUpdateRequest request); + + /** 회차 삭제. 소프트 삭제라 행은 남고 조회에서만 빠진다. */ + @Operation(summary = "사물함 운영 회차 삭제", + description = "운영 회차를 삭제한다. 신청이 한 건이라도 있으면 삭제하지 않는다. 데이터는 남기고 조회에서만 빠진다.") + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_HAS_APPLICATIONS"}) + ApiResponse deleteLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodController.java new file mode 100644 index 00000000..9e63ba48 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerPeriodController.java @@ -0,0 +1,71 @@ +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.LockerPeriodCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodUpdateRequest; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/lockers/periods") +@RequiredArgsConstructor +public class AdminLockerPeriodController implements AdminLockerPeriodApi { + + private final LockerService lockerService; + + @Override + @GetMapping + public ApiResponse getLockerPeriods(AdminApiUser apiUser) { + return ApiResponse.success(AdminLockerPeriodListResponse.from(lockerService.getAllPeriods())); + } + + @Override + @GetMapping("/{lockerPeriodId}") + public ApiResponse getLockerPeriod( + AdminApiUser apiUser, + @PathVariable Long lockerPeriodId + ) { + return ApiResponse.success( + AdminLockerPeriodDetailResponse.from(lockerService.getPeriodById(lockerPeriodId))); + } + + @Override + @PostMapping + public ApiResponse createLockerPeriod( + AdminApiUser apiUser, + @Valid @RequestBody LockerPeriodCreateRequest request + ) { + lockerService.createPeriod(request.toCommand()); + return ApiResponse.success(); + } + + @Override + @PutMapping("/{lockerPeriodId}") + public ApiResponse updateLockerPeriod( + AdminApiUser apiUser, + @PathVariable Long lockerPeriodId, + @Valid @RequestBody LockerPeriodUpdateRequest request + ) { + lockerService.updatePeriod(lockerPeriodId, request.toCommand()); + return ApiResponse.success(); + } + + @Override + @DeleteMapping("/{lockerPeriodId}") + public ApiResponse deleteLockerPeriod(AdminApiUser apiUser, @PathVariable Long lockerPeriodId) { + lockerService.deletePeriod(lockerPeriodId); + return ApiResponse.success(); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodCreateRequest.java new file mode 100644 index 00000000..2ca73dc2 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodCreateRequest.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand; + +/** + * 회차 이름은 연도와 학기로 서버가 만든다. 게시 여부도 받지 않는다 — 학생에게 회차를 노출하는 경로는 홈 화면 배너뿐이고 + * 그쪽에서 따로 설정한다. + *

+ * 신청 기간은 날짜만 받고 시작일 00:00:00 ~ 마감일 23:59:59로 저장한다. + */ +public record LockerPeriodCreateRequest( + @NotNull(message = "연도를 입력해 주세요.") + @Min(value = 2000, message = "연도가 올바르지 않습니다.") + @Max(value = 2100, message = "연도가 올바르지 않습니다.") + Integer year, + + @NotNull(message = "학기를 입력해 주세요.") + @Min(value = 1, message = "학기는 1 또는 2여야 합니다.") + @Max(value = 2, message = "학기는 1 또는 2여야 합니다.") + Integer semester, + + @NotNull(message = "신청 시작일을 입력해 주세요.") + LocalDate applyStartDate, + + @NotNull(message = "신청 마감일을 입력해 주세요.") + LocalDate applyEndDate, + + @NotNull(message = "사용 시작일을 입력해 주세요.") + LocalDate usageStartDate, + + @NotNull(message = "사용 종료일을 입력해 주세요.") + LocalDate usageEndDate +) { + + public LockerPeriodCreateCommand toCommand() { + return new LockerPeriodCreateCommand( + year, semester, applyStartDate, applyEndDate, usageStartDate, usageEndDate); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodUpdateRequest.java new file mode 100644 index 00000000..9a9c1abe --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodUpdateRequest.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand; + +/** + * 전체 교체다. 바꾸지 않는 값도 원래 값 그대로 보낸다. 연도·학기와 게시 여부는 이 경로로 바뀌지 않는다. + *

+ * 진행 중인 회차는 신청 시작일을 원래 값 그대로 보내야 한다. + */ +public record LockerPeriodUpdateRequest( + @NotNull(message = "신청 시작일을 입력해 주세요.") + LocalDate applyStartDate, + + @NotNull(message = "신청 마감일을 입력해 주세요.") + LocalDate applyEndDate, + + @NotNull(message = "사용 시작일을 입력해 주세요.") + LocalDate usageStartDate, + + @NotNull(message = "사용 종료일을 입력해 주세요.") + LocalDate usageEndDate +) { + + public LockerPeriodUpdateCommand toCommand() { + return new LockerPeriodUpdateCommand(applyStartDate, applyEndDate, usageStartDate, usageEndDate); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodDetailResponse.java new file mode 100644 index 00000000..fea18c86 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodDetailResponse.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; + +/** + * 수정 화면의 기존 값을 채우는 데 쓴다. 연도·학기는 수정할 수 없지만 화면에 보여줘야 해서 따로 내려준다. + */ +public record AdminLockerPeriodDetailResponse( + Long lockerPeriodId, + String lockerPeriodName, + int year, + int semester, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + LocalDate usageStartDate, + LocalDate usageEndDate, + LockerPeriodStatus periodStatus +) { + + public static AdminLockerPeriodDetailResponse from(LockerPeriodSummary summary) { + LockerPeriod period = summary.period(); + return new AdminLockerPeriodDetailResponse( + period.getId(), + period.getName(), + period.getYear(), + period.getSemester(), + period.getApplyStartAt(), + period.getApplyEndAt(), + period.getUsageStartAt(), + period.getUsageEndAt(), + summary.status() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListItemResponse.java new file mode 100644 index 00000000..18dec498 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListItemResponse.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; + +public record AdminLockerPeriodListItemResponse( + Long lockerPeriodId, + String lockerPeriodName, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + LocalDate usageStartDate, + LocalDate usageEndDate, + LockerPeriodStatus periodStatus +) { + + public static AdminLockerPeriodListItemResponse from(LockerPeriodSummary summary) { + LockerPeriod period = summary.period(); + return new AdminLockerPeriodListItemResponse( + period.getId(), + period.getName(), + period.getApplyStartAt(), + period.getApplyEndAt(), + period.getUsageStartAt(), + period.getUsageEndAt(), + summary.status() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListResponse.java new file mode 100644 index 00000000..7841d0bb --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerPeriodListResponse.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary; + +/** + * @param currentPeriod 진행 중인 회차. 없으면 {@code null}. 회차 기간은 서로 겹치지 않으므로 최대 한 건이다 + * @param periods 전체 회차. 진행 중인 회차도 함께 들어 있다 + */ +public record AdminLockerPeriodListResponse( + AdminLockerPeriodListItemResponse currentPeriod, + List periods +) { + + public static AdminLockerPeriodListResponse from(List summaries) { + return new AdminLockerPeriodListResponse( + summaries.stream() + .filter(summary -> summary.status() == LockerPeriodStatus.IN_PROGRESS) + .findFirst() + .map(AdminLockerPeriodListItemResponse::from) + .orElse(null), + summaries.stream() + .map(AdminLockerPeriodListItemResponse::from) + .toList() + ); + } +}