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()
+ );
+ }
+}
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/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/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)) {
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),
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);