diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
new file mode 100644
index 00000000..20811a77
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
@@ -0,0 +1,46 @@
+package kr.ac.kookmin.stream.api.app.event.locker;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 학생 앱 사물함 API의 문서 명세. 구현은 {@link AppLockerController}가 맡는다.
+ *
+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
+ * 파라미터 바인딩(@{@code ModelAttribute}, @{@code PathVariable} 등)은 구현체에 둔다.
+ */
+@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회")
+public interface AppLockerApi {
+
+ /** 구역별 전체·선택 가능 사물함 수와 표시 상태. */
+ @Operation(summary = "사물함 구역 목록 조회",
+ description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. "
+ + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
+ ApiResponse getSections(
+ AppApiUser apiUser,
+ @ParameterObject LockerPeriodParams params
+ );
+
+ /** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */
+ @Operation(summary = "사물함 구역 상세 조회",
+ description = "구역에 속한 사물함의 배치도 위치와 선택 가능 여부를 조회한다. "
+ + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
+ ApiResponse getSectionLockers(
+ AppApiUser apiUser,
+ Long sectionId,
+ @ParameterObject LockerPeriodParams params
+ );
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java
new file mode 100644
index 00000000..4f662ec7
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java
@@ -0,0 +1,65 @@
+package kr.ac.kookmin.stream.api.app.event.locker;
+
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.Set;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 학생 앱의 사물함 구역·배치 조회 API.
+ *
+ * 선택 가능 여부와 내 사물함 표시는 조회 결과를 응답 DTO에서 맞춰봐서 만든다. 미게시 회차·없는 구역
+ * 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다.
+ */
+@RestController
+@RequestMapping("/v1/app/lockers")
+@RequiredArgsConstructor
+public class AppLockerController implements AppLockerApi {
+
+ private final LockerService lockerService;
+
+ @Override
+ @GetMapping("/sections")
+ public ApiResponse getSections(
+ AppApiUser apiUser,
+ @Valid @ModelAttribute LockerPeriodParams params
+ ) {
+ Long lockerPeriodId = params.lockerPeriodId();
+ List sections = lockerService.getSections(lockerPeriodId);
+ Long mySectionId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
+ .map(Locker::getSectionId)
+ .orElse(null);
+
+ return ApiResponse.success(LockerSectionListResponse.of(sections, mySectionId));
+ }
+
+ @Override
+ @GetMapping("/sections/{sectionId}")
+ public ApiResponse getSectionLockers(
+ AppApiUser apiUser,
+ @PathVariable Long sectionId,
+ @Valid @ModelAttribute LockerPeriodParams params
+ ) {
+ Long lockerPeriodId = params.lockerPeriodId();
+ List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId);
+ Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
+ Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
+ .map(Locker::getId)
+ .orElse(null);
+
+ return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId));
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java
new file mode 100644
index 00000000..eeb9227f
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java
@@ -0,0 +1,13 @@
+package kr.ac.kookmin.stream.api.app.event.locker.request;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * @param lockerPeriodId 조회할 사물함 운영 회차 식별자
+ */
+public record LockerPeriodParams(
+
+ @NotNull(message = "사물함 운영 회차를 입력해 주세요.")
+ Long lockerPeriodId
+) {
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java
new file mode 100644
index 00000000..fb04cf58
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java
@@ -0,0 +1,31 @@
+package kr.ac.kookmin.stream.api.app.event.locker.response;
+
+import java.util.Objects;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+
+public record LockerResponse(
+ Long lockerId,
+ String lockerLabel,
+ int lockerNumber,
+ int rowNo,
+ int columnNo,
+ boolean isAvailable,
+ boolean isMine
+) {
+
+ /**
+ * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
+ * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
+ */
+ public static LockerResponse of(Locker locker, boolean applied, Long myLockerId) {
+ return new LockerResponse(
+ locker.getId(),
+ locker.getLockerLabel(),
+ locker.getLockerNumber(),
+ locker.getRowNo(),
+ locker.getColumnNo(),
+ locker.isSelectable(applied),
+ Objects.equals(locker.getId(), myLockerId)
+ );
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
new file mode 100644
index 00000000..4880c6a2
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.api.app.event.locker.response;
+
+import java.util.List;
+import java.util.Set;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+
+public record LockerSectionDetailResponse(List lockers) {
+
+ /**
+ * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
+ * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
+ */
+ public static LockerSectionDetailResponse of(
+ List lockers,
+ Set appliedLockerIds,
+ Long myLockerId
+ ) {
+ return new LockerSectionDetailResponse(lockers.stream()
+ .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
+ .toList());
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java
new file mode 100644
index 00000000..37768926
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java
@@ -0,0 +1,29 @@
+package kr.ac.kookmin.stream.api.app.event.locker.response;
+
+import java.util.Objects;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus;
+
+public record LockerSectionListItemResponse(
+ Long sectionId,
+ String section,
+ int availableCount,
+ int totalCount,
+ SectionAvailabilityStatus availabilityStatus,
+ boolean hasMine
+) {
+
+ /**
+ * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null}
+ */
+ public static LockerSectionListItemResponse of(LockerSectionSummary summary, Long mySectionId) {
+ return new LockerSectionListItemResponse(
+ summary.sectionId(),
+ summary.label(),
+ summary.availableCount(),
+ summary.totalCount(),
+ summary.availabilityStatus(),
+ Objects.equals(summary.sectionId(), mySectionId)
+ );
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java
new file mode 100644
index 00000000..33e02a80
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.api.app.event.locker.response;
+
+import java.util.List;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+
+public record LockerSectionListResponse(List sections) {
+
+ /**
+ * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null}
+ */
+ public static LockerSectionListResponse of(List sections, Long mySectionId) {
+ return new LockerSectionListResponse(sections.stream()
+ .map(summary -> LockerSectionListItemResponse.of(summary, mySectionId))
+ .toList());
+ }
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
index 83d9bd56..552dc6f8 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
@@ -11,20 +11,35 @@
public class Locker {
private Long id;
- private String lockerNumber;
- private String section;
+ private Long sectionId;
+ /** 화면에 표시할 사물함 이름. "A-37" 형태로 관리자가 직접 입력하며 번호에서 유도하지 않는다. */
+ private String lockerLabel;
+ /** 블록 안에서 이어지는 사물함 순번. A-1 구역이 38번까지면 A-2 구역은 39번부터 시작한다. */
+ private int lockerNumber;
private int rowNo;
private int columnNo;
private LockerStatus status;
public static Locker of(
Long id,
- String lockerNumber,
- String section,
+ Long sectionId,
+ String lockerLabel,
+ int lockerNumber,
int rowNo,
int columnNo,
LockerStatus status
) {
- return new Locker(id, lockerNumber, section, rowNo, columnNo, status);
+ return new Locker(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status);
+ }
+
+ /**
+ * 해당 운영 회차에서 선택할 수 있는지. 사물함 자체 상태와 신청 여부를 함께 본다.
+ *
+ * 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 같은 기준을 써야 하므로 도메인에 둔다.
+ *
+ * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
+ */
+ public boolean isSelectable(boolean applied) {
+ return status == LockerStatus.AVAILABLE && !applied;
}
}
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
new file mode 100644
index 00000000..c71c375d
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java
@@ -0,0 +1,19 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import kr.ac.kookmin.stream.common.ErrorCode;
+import kr.ac.kookmin.stream.common.ErrorStatus;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor
+public enum LockerErrorCode implements ErrorCode {
+
+ LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."),
+ LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다.");
+
+ private final int status;
+ private final String message;
+}
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 04e16cb1..4734aca4 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
@@ -18,6 +18,8 @@ public class LockerPeriod {
private LocalDateTime applyEndAt;
private LocalDate usageStartAt;
private LocalDate usageEndAt;
+ /** 운영진이 학생에게 공개했는지. 미게시 회차는 학생에게 없는 것으로 보여야 한다. */
+ private boolean published;
public static LockerPeriod of(
Long id,
@@ -25,8 +27,9 @@ public static LockerPeriod of(
LocalDateTime applyStartAt,
LocalDateTime applyEndAt,
LocalDate usageStartAt,
- LocalDate usageEndAt
+ LocalDate usageEndAt,
+ boolean published
) {
- return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt);
+ return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published);
}
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java
new file mode 100644
index 00000000..3e5d22c1
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * 사물함 구역. 사물함을 묶는 단위이자 구역 목록·구역 상세 조회의 기준이다.
+ */
+@Getter
+@EqualsAndHashCode
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class LockerSection {
+
+ private Long id;
+ private String label;
+
+ public static LockerSection of(Long id, String label) {
+ return new LockerSection(id, label);
+ }
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java
new file mode 100644
index 00000000..ddc68180
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java
@@ -0,0 +1,13 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+/**
+ * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다.
+ */
+public record LockerSectionSummary(
+ Long sectionId,
+ String label,
+ int availableCount,
+ int totalCount,
+ SectionAvailabilityStatus availabilityStatus
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java
new file mode 100644
index 00000000..61651b68
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java
@@ -0,0 +1,38 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+/**
+ * 구역에 남은 사물함 수로 정해지는 표시 상태.
+ *
+ * 판정에 구역의 속성이 쓰이지 않고 선택 가능 수와 전체 수만 필요해, 상태 타입이 자기 생성을 소유한다.
+ * 구역별로 임계값이 달라지면 그때 {@link LockerSection}이 임계값을 갖고 판정을 가져가는 편이 맞다.
+ */
+public enum SectionAvailabilityStatus {
+
+ PLENTY,
+ NORMAL,
+ ALMOST_FULL,
+ FULL;
+
+ private static final double PLENTY_RATE = 0.5;
+ private static final double NORMAL_RATE = 0.2;
+
+ /**
+ * @param availableCount 선택 가능한 사물함 수
+ * @param totalCount 구역의 전체 사물함 수(사용 중지된 사물함 포함)
+ */
+ public static SectionAvailabilityStatus from(int availableCount, int totalCount) {
+ // 사물함이 하나도 없는 구역의 0 나누기도 여기서 함께 걸린다
+ if (availableCount <= 0) {
+ return FULL;
+ }
+
+ double availabilityRate = (double) availableCount / totalCount;
+ if (availabilityRate >= PLENTY_RATE) {
+ return PLENTY;
+ }
+ if (availabilityRate >= NORMAL_RATE) {
+ return NORMAL;
+ }
+ return ALMOST_FULL;
+ }
+}
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
new file mode 100644
index 00000000..9aa40667
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java
@@ -0,0 +1,47 @@
+package kr.ac.kookmin.stream.event.domain.locker.repository;
+
+import java.util.List;
+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.LockerSection;
+
+public interface LockerRepository {
+
+ /**
+ * 게시된 운영 회차가 있는지. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 한다.
+ */
+ boolean existsPublishedPeriod(Long lockerPeriodId);
+
+ boolean existsSection(Long sectionId);
+
+ /**
+ * 전체 구역을 식별자 오름차순으로 조회한다.
+ */
+ List findAllSections();
+
+ /**
+ * 삭제되지 않은 사물함 전체를 조회한다. 구역별 집계는 서비스가 이 목록을 묶어 센다.
+ */
+ List findAllLockers();
+
+ /**
+ * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다.
+ */
+ List findLockersBySectionId(Long sectionId);
+
+ /**
+ * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다.
+ */
+ Optional findLockerById(Long lockerId);
+
+ /**
+ * 해당 운영 회차에 이미 신청된 사물함 식별자.
+ */
+ Set findAppliedLockerIds(Long lockerPeriodId);
+
+ /**
+ * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다.
+ */
+ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId);
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java
new file mode 100644
index 00000000..c6730023
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java
@@ -0,0 +1,44 @@
+package kr.ac.kookmin.stream.event.domain.locker.service;
+
+import java.util.List;
+import java.util.Map;
+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.LockerSectionSummary;
+
+public interface LockerService {
+
+ /**
+ * 운영 회차의 구역별 전체/선택 가능 사물함 수와 표시 상태를 조회한다.
+ */
+ List getSections(Long lockerPeriodId);
+
+ /**
+ * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다.
+ *
+ * 선택 가능 여부는 담지 않는다. 판정은 {@link Locker#isSelectable(boolean)}이 갖고 있고, 신청 여부는
+ * {@link #getAppliedLockerIds(Long)}로 함께 읽어 호출하는 쪽에서 맞춰본다.
+ */
+ List getSectionLockers(Long lockerPeriodId, Long sectionId);
+
+ /**
+ * 해당 운영 회차에 이미 신청된 사물함 식별자.
+ */
+ Set getAppliedLockerIds(Long lockerPeriodId);
+
+ /**
+ * 삭제되지 않은 사물함 전체를 구역별로 묶어 조회한다.
+ *
+ * 사물함이 한 건도 없는 구역은 키가 없다. 구역 목록을 만들 때처럼 빈 구역도 빠뜨리면 안 되는 쪽은
+ * {@code getOrDefault}로 받아야 한다.
+ */
+ Map> getLockerMapBySectionId();
+
+ /**
+ * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다.
+ *
+ * 회차 게시 여부와 노출 범위는 확인하지 않는다. 호출하는 쪽이 판단한다.
+ */
+ Optional getLockerByMemberId(Long lockerPeriodId, Long memberId);
+}
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
new file mode 100644
index 00000000..1f6d356d
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java
@@ -0,0 +1,106 @@
+package kr.ac.kookmin.stream.event.domain.locker.service.impl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus;
+import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
+import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+class LockerServiceImpl implements LockerService {
+
+ private final LockerRepository lockerRepository;
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getSections(Long lockerPeriodId) {
+ requirePublishedPeriod(lockerPeriodId);
+
+ Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId);
+ Map> lockersBySection = getLockerMapBySectionId();
+
+ return lockerRepository.findAllSections().stream()
+ .map(section -> summarize(
+ section,
+ // 사물함이 한 건도 없는 구역은 묶음에 키가 없다. 빈 목록으로 채워 목록에서 빠지지 않게 한다
+ lockersBySection.getOrDefault(section.getId(), List.of()),
+ appliedLockerIds
+ ))
+ .toList();
+ }
+
+ @Override
+ public List getSectionLockers(Long lockerPeriodId, Long sectionId) {
+ requirePublishedPeriod(lockerPeriodId);
+ requireSection(sectionId);
+
+ return lockerRepository.findLockersBySectionId(sectionId);
+ }
+
+ @Override
+ public Set getAppliedLockerIds(Long lockerPeriodId) {
+ return lockerRepository.findAppliedLockerIds(lockerPeriodId);
+ }
+
+ @Override
+ public Map> getLockerMapBySectionId() {
+ return lockerRepository.findAllLockers().stream()
+ .collect(Collectors.groupingBy(Locker::getSectionId));
+ }
+
+ @Override
+ public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) {
+ return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId)
+ .flatMap(lockerRepository::findLockerById);
+ }
+
+ /**
+ * 구역 하나의 전체·선택 가능 수를 센다.
+ *
+ * @param lockers 구역에 속한 사물함. 사물함이 없는 구역이면 빈 목록이다
+ * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
+ */
+ private LockerSectionSummary summarize(
+ LockerSection section,
+ List lockers,
+ Set appliedLockerIds
+ ) {
+ int totalCount = lockers.size();
+ int availableCount = (int) lockers.stream()
+ .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId())))
+ .count();
+
+ return new LockerSectionSummary(
+ section.getId(),
+ section.getLabel(),
+ availableCount,
+ totalCount,
+ SectionAvailabilityStatus.from(availableCount, totalCount)
+ );
+ }
+
+ /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */
+ private void requirePublishedPeriod(Long lockerPeriodId) {
+ if (!lockerRepository.existsPublishedPeriod(lockerPeriodId)) {
+ throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND);
+ }
+ }
+
+ private void requireSection(Long sectionId) {
+ if (!lockerRepository.existsSection(sectionId)) {
+ throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND);
+ }
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java
new file mode 100644
index 00000000..d922e3ae
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java
@@ -0,0 +1,41 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 선택 가능 판정은 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 함께 쓰는 규칙이라 여기서 덮는다.
+ */
+class LockerTest {
+
+ private static Locker locker(LockerStatus status) {
+ return Locker.of(1L, 10L, "A-1", 1, 1, 1, status);
+ }
+
+ @Test
+ @DisplayName("사용 가능하고 신청되지 않았으면 선택할 수 있다")
+ void selectableWhenAvailableAndNotApplied() {
+ assertTrue(locker(LockerStatus.AVAILABLE).isSelectable(false));
+ }
+
+ @Test
+ @DisplayName("사용 가능해도 이미 신청됐으면 선택할 수 없다")
+ void notSelectableWhenApplied() {
+ assertFalse(locker(LockerStatus.AVAILABLE).isSelectable(true));
+ }
+
+ @Test
+ @DisplayName("사용 중지된 사물함은 신청되지 않았어도 선택할 수 없다")
+ void notSelectableWhenDisabled() {
+ assertFalse(locker(LockerStatus.DISABLED).isSelectable(false));
+ }
+
+ @Test
+ @DisplayName("사용 중지됐고 신청까지 됐으면 선택할 수 없다")
+ void notSelectableWhenDisabledAndApplied() {
+ assertFalse(locker(LockerStatus.DISABLED).isSelectable(true));
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java
new file mode 100644
index 00000000..4b2c273b
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java
@@ -0,0 +1,49 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class SectionAvailabilityStatusTest {
+
+ @ParameterizedTest
+ @DisplayName("명세의 예시가 모두 같은 상태로 판정된다")
+ @CsvSource({
+ "24, 34, PLENTY", // 70.6%
+ "6, 6, PLENTY", // 100%
+ "24, 60, NORMAL", // 40%
+ "2, 40, ALMOST_FULL", // 5%
+ "0, 100, FULL" // 0%
+ })
+ void specExamples(int available, int total, SectionAvailabilityStatus expected) {
+ assertEquals(expected, SectionAvailabilityStatus.from(available, total));
+ }
+
+ @ParameterizedTest
+ @DisplayName("잔여율 경계값은 더 여유로운 쪽으로 판정된다")
+ @CsvSource({
+ "50, 100, PLENTY", // 정확히 50%
+ "49, 100, NORMAL", // 50% 바로 아래
+ "20, 100, NORMAL", // 정확히 20%
+ "19, 100, ALMOST_FULL" // 20% 바로 아래
+ })
+ void boundaries(int available, int total, SectionAvailabilityStatus expected) {
+ assertEquals(expected, SectionAvailabilityStatus.from(available, total));
+ }
+
+ @Test
+ @DisplayName("선택 가능 수가 0이면 전체 수와 무관하게 마감이다")
+ void zeroAvailableIsFull() {
+ assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 100));
+ assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 1));
+ }
+
+ @Test
+ @DisplayName("사물함이 없는 구역은 0으로 나누지 않고 마감으로 본다")
+ void emptySection() {
+ assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 0));
+ }
+}
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
new file mode 100644
index 00000000..d1262c57
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java
@@ -0,0 +1,273 @@
+package kr.ac.kookmin.stream.event.domain.locker.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+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.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
+import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus;
+import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계가 어긋나지 않는지 확인한다.
+ *
+ * 선택 가능 여부와 내 사물함 표시는 표현 계층이 조회 결과를 맞춰봐서 만든다. 판정 규칙 자체는
+ * {@code LockerTest}가 덮으므로 여기서는 조회가 무엇을 돌려주는지만 본다.
+ */
+class LockerServiceImplTest {
+
+ private static final Long PERIOD_ID = 1L;
+ private static final Long MEMBER_ID = 100L;
+
+ private static LockerSection section(Long id, String label) {
+ return LockerSection.of(id, label);
+ }
+
+ private static Locker locker(Long id, Long sectionId, LockerStatus status) {
+ return Locker.of(id, sectionId, "A-" + id, id.intValue(), 1, id.intValue(), status);
+ }
+
+ private static Locker usable(Long id, Long sectionId) {
+ return locker(id, sectionId, LockerStatus.AVAILABLE);
+ }
+
+ @Nested
+ @DisplayName("구역 목록")
+ class GetSections {
+
+ @Test
+ @DisplayName("구역마다 자기 사물함만 세고, 사용 중지·신청된 것은 선택 가능 수에서 빠진다")
+ void countsPerSection() {
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withSections(section(1L, "A-1"), section(2L, "A-2"))
+ .withAllLockers(
+ usable(11L, 1L),
+ locker(12L, 1L, LockerStatus.DISABLED),
+ usable(13L, 1L),
+ usable(21L, 2L))
+ .withAppliedLockerIds(13L);
+
+ List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID);
+
+ assertEquals(List.of(1L, 2L), sections.stream().map(LockerSectionSummary::sectionId).toList());
+ assertEquals(3, sections.get(0).totalCount());
+ assertEquals(1, sections.get(0).availableCount()); // 12는 사용 중지, 13은 신청됨
+ assertEquals(1, sections.get(1).totalCount());
+ assertEquals(1, sections.get(1).availableCount());
+ }
+
+ @Test
+ @DisplayName("사물함이 한 건도 없는 구역도 0으로 채워 목록에서 빠지지 않는다")
+ void keepsSectionsWithoutLockers() {
+ // 구역별로 묶으면 사물함이 없는 2번 구역은 키 자체가 생기지 않는다
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withSections(section(1L, "A-1"), section(2L, "A-2"))
+ .withAllLockers(usable(11L, 1L));
+
+ List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID);
+
+ assertEquals(2, sections.size());
+ assertEquals(0, sections.get(1).totalCount());
+ assertEquals(SectionAvailabilityStatus.FULL, sections.get(1).availabilityStatus());
+ }
+
+ @Test
+ @DisplayName("게시되지 않은 회차는 찾을 수 없다")
+ void unpublishedPeriod() {
+ FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod();
+
+ BusinessException e = assertThrows(BusinessException.class,
+ () -> new LockerServiceImpl(repository).getSections(PERIOD_ID));
+
+ assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode());
+ }
+ }
+
+ @Nested
+ @DisplayName("구역 상세")
+ class GetSectionLockers {
+
+ @Test
+ @DisplayName("구역의 사물함을 레포지토리가 준 순서대로 돌려준다")
+ void returnsSectionLockers() {
+ // 선택 가능 판정은 담지 않는다. 판정 자체는 LockerTest가 덮는다
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withSectionLockers(
+ usable(11L, 1L),
+ locker(12L, 1L, LockerStatus.DISABLED),
+ usable(13L, 1L));
+
+ List lockers = new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L);
+
+ assertEquals(List.of(11L, 12L, 13L), lockers.stream().map(Locker::getId).toList());
+ }
+
+ @Test
+ @DisplayName("게시되지 않은 회차는 찾을 수 없다")
+ void unpublishedPeriod() {
+ FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod();
+
+ BusinessException e = assertThrows(BusinessException.class,
+ () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L));
+
+ assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode());
+ }
+
+ @Test
+ @DisplayName("없는 구역은 찾을 수 없다")
+ void unknownSection() {
+ FakeLockerRepository repository = new FakeLockerRepository().withMissingSection();
+
+ BusinessException e = assertThrows(BusinessException.class,
+ () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L));
+
+ assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode());
+ }
+ }
+
+ @Nested
+ @DisplayName("회원이 신청한 사물함")
+ class GetLockerByMemberId {
+
+ @Test
+ @DisplayName("신청한 사물함을 구역까지 담아 돌려준다")
+ void returnsAppliedLocker() {
+ Locker mine = usable(21L, 2L);
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withAllLockers(usable(11L, 1L), mine)
+ .withMyLocker(mine);
+
+ Optional found = new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID);
+
+ assertTrue(found.isPresent());
+ assertEquals(21L, found.get().getId());
+ assertEquals(2L, found.get().getSectionId());
+ }
+
+ @Test
+ @DisplayName("신청하지 않았으면 비어 있다")
+ void emptyWhenNotApplied() {
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withAllLockers(usable(11L, 1L));
+
+ assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty());
+ }
+
+ @Test
+ @DisplayName("신청한 사물함이 삭제됐으면 비어 있다")
+ void emptyWhenLockerRemoved() {
+ // 신청 행은 남아 있는데 사물함이 삭제된 경우. 사물함 조회가 삭제된 건을 거르므로 여기서도 없는 것이 된다
+ FakeLockerRepository repository = new FakeLockerRepository()
+ .withAllLockers(usable(11L, 1L))
+ .withMyLockerId(99L);
+
+ assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty());
+ }
+ }
+
+ /** 조회 결과만 답하고 집계는 서비스가 하는지 보기 위한 가짜 레포지토리. */
+ private static final class FakeLockerRepository implements LockerRepository {
+
+ private boolean publishedPeriod = true;
+ private boolean sectionExists = true;
+ private List sections = List.of();
+ private List allLockers = List.of();
+ private List sectionLockers = List.of();
+ private Set appliedLockerIds = Set.of();
+ private Long myLockerId;
+
+ FakeLockerRepository withSections(LockerSection... values) {
+ this.sections = List.of(values);
+ return this;
+ }
+
+ FakeLockerRepository withAllLockers(Locker... values) {
+ this.allLockers = List.of(values);
+ return this;
+ }
+
+ FakeLockerRepository withSectionLockers(Locker... values) {
+ this.sectionLockers = List.of(values);
+ return this;
+ }
+
+ FakeLockerRepository withAppliedLockerIds(Long... values) {
+ this.appliedLockerIds = Set.of(values);
+ return this;
+ }
+
+ FakeLockerRepository withMyLocker(Locker value) {
+ return withMyLockerId(value.getId());
+ }
+
+ /** 사물함 목록에 없는 식별자를 넣으면 신청 행만 남고 사물함은 삭제된 상황이 된다. */
+ FakeLockerRepository withMyLockerId(Long value) {
+ this.myLockerId = value;
+ return this;
+ }
+
+ FakeLockerRepository withUnpublishedPeriod() {
+ this.publishedPeriod = false;
+ return this;
+ }
+
+ FakeLockerRepository withMissingSection() {
+ this.sectionExists = false;
+ return this;
+ }
+
+ @Override
+ public boolean existsPublishedPeriod(Long lockerPeriodId) {
+ return publishedPeriod;
+ }
+
+ @Override
+ public boolean existsSection(Long sectionId) {
+ return sectionExists;
+ }
+
+ @Override
+ public List findAllSections() {
+ return sections;
+ }
+
+ @Override
+ public List findAllLockers() {
+ return allLockers;
+ }
+
+ @Override
+ public List findLockersBySectionId(Long sectionId) {
+ return sectionLockers;
+ }
+
+ @Override
+ public Optional findLockerById(Long lockerId) {
+ return Stream.concat(allLockers.stream(), sectionLockers.stream())
+ .filter(locker -> locker.getId().equals(lockerId))
+ .findFirst();
+ }
+
+ @Override
+ public Set findAppliedLockerIds(Long lockerPeriodId) {
+ return appliedLockerIds;
+ }
+
+ @Override
+ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) {
+ return Optional.ofNullable(myLockerId);
+ }
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java
index 43da1796..fcce791d 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java
@@ -17,7 +17,12 @@
name = "locker_applications",
indexes = {
@Index(name = "idx_locker_applications_locker_period_id_locker_id", columnList = "locker_period_id, locker_id"),
- @Index(name = "idx_locker_applications_member_id", columnList = "member_id")
+ @Index(name = "idx_locker_applications_member_id", columnList = "member_id"),
+ @Index(
+ name = "uk_locker_applications_locker_period_id_member_id",
+ columnList = "locker_period_id, member_id",
+ unique = true
+ )
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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
new file mode 100644
index 00000000..a2838015
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java
@@ -0,0 +1,31 @@
+package kr.ac.kookmin.stream.db.event;
+
+import java.util.Optional;
+import java.util.Set;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface LockerApplicationJpaRepository extends JpaRepository {
+
+ /**
+ * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다.
+ */
+ @Query("""
+ SELECT a.lockerId FROM LockerApplicationJpaEntity a
+ WHERE a.lockerPeriodId = :lockerPeriodId
+ """)
+ Set findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId);
+
+ /**
+ * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다.
+ */
+ @Query("""
+ SELECT a.lockerId FROM LockerApplicationJpaEntity a
+ WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId
+ """)
+ Optional findLockerIdByLockerPeriodIdAndMemberId(
+ @Param("lockerPeriodId") Long lockerPeriodId,
+ @Param("memberId") Long memberId
+ );
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java
index 31d09d11..9c38a8af 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java
@@ -7,6 +7,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.Table;
import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
@@ -15,7 +16,13 @@
import lombok.NoArgsConstructor;
@Entity
-@Table(name = "lockers")
+@Table(
+ name = "lockers",
+ indexes = @Index(
+ name = "idx_lockers_section_id_is_deleted_row_no_column_no",
+ columnList = "section_id, is_deleted, row_no, column_no"
+ )
+)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LockerJpaEntity extends BaseSoftDeleteEntity {
@@ -24,11 +31,14 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity {
@Column(name = "locker_id")
private Long id;
- @Column(name = "locker_number", nullable = false, length = 20)
- private String lockerNumber;
+ @Column(name = "section_id", nullable = false)
+ private Long sectionId;
- @Column(nullable = false, length = 20)
- private String section;
+ @Column(name = "locker_label", nullable = false, length = 20)
+ private String lockerLabel;
+
+ @Column(name = "locker_number", nullable = false)
+ private int lockerNumber;
@Column(name = "row_no", nullable = false)
private int rowNo;
@@ -42,8 +52,9 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity {
private LockerJpaEntity(Locker locker) {
this.id = locker.getId();
+ this.sectionId = locker.getSectionId();
+ this.lockerLabel = locker.getLockerLabel();
this.lockerNumber = locker.getLockerNumber();
- this.section = locker.getSection();
this.rowNo = locker.getRowNo();
this.columnNo = locker.getColumnNo();
this.status = locker.getStatus();
@@ -54,6 +65,6 @@ public static LockerJpaEntity from(Locker locker) {
}
public Locker toDomain() {
- return Locker.of(id, lockerNumber, section, rowNo, columnNo, status);
+ return Locker.of(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java
new file mode 100644
index 00000000..6ed2124e
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.db.event;
+
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LockerJpaRepository extends JpaRepository {
+
+ List findAllByIsDeletedFalse();
+
+ Optional findByIdAndIsDeletedFalse(Long id);
+
+ /**
+ * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로
+ * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다.
+ */
+ List findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(Long sectionId);
+}
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 98262c4c..afcac785 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
@@ -38,6 +38,9 @@ public class LockerPeriodJpaEntity extends BaseTimeEntity {
@Column(name = "usage_end_at", nullable = false)
private LocalDate usageEndAt;
+ @Column(name = "is_published", nullable = false)
+ private boolean isPublished;
+
private LockerPeriodJpaEntity(LockerPeriod period) {
this.id = period.getId();
this.name = period.getName();
@@ -45,6 +48,7 @@ private LockerPeriodJpaEntity(LockerPeriod period) {
this.applyEndAt = period.getApplyEndAt();
this.usageStartAt = period.getUsageStartAt();
this.usageEndAt = period.getUsageEndAt();
+ this.isPublished = period.isPublished();
}
public static LockerPeriodJpaEntity from(LockerPeriod period) {
@@ -52,6 +56,6 @@ public static LockerPeriodJpaEntity from(LockerPeriod period) {
}
public LockerPeriod toDomain() {
- return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt);
+ return LockerPeriod.of(id, 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
new file mode 100644
index 00000000..c06ffea6
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.db.event;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LockerPeriodJpaRepository extends JpaRepository {
+
+ boolean existsByIdAndIsPublishedTrue(Long id);
+}
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
new file mode 100644
index 00000000..38dd8fcf
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java
@@ -0,0 +1,67 @@
+package kr.ac.kookmin.stream.db.event;
+
+import java.util.List;
+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.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class LockerRepositoryImpl implements LockerRepository {
+
+ private final LockerPeriodJpaRepository lockerPeriodJpaRepository;
+ private final LockerSectionJpaRepository lockerSectionJpaRepository;
+ private final LockerJpaRepository lockerJpaRepository;
+ private final LockerApplicationJpaRepository lockerApplicationJpaRepository;
+
+ @Override
+ public boolean existsPublishedPeriod(Long lockerPeriodId) {
+ return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrue(lockerPeriodId);
+ }
+
+ @Override
+ public boolean existsSection(Long sectionId) {
+ return lockerSectionJpaRepository.existsById(sectionId);
+ }
+
+ @Override
+ public List findAllSections() {
+ return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream()
+ .map(LockerSectionJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public List findAllLockers() {
+ return lockerJpaRepository.findAllByIsDeletedFalse().stream()
+ .map(LockerJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public List findLockersBySectionId(Long sectionId) {
+ return lockerJpaRepository.findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(sectionId).stream()
+ .map(LockerJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public Optional findLockerById(Long lockerId) {
+ return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId)
+ .map(LockerJpaEntity::toDomain);
+ }
+
+ @Override
+ public Set findAppliedLockerIds(Long lockerPeriodId) {
+ return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId);
+ }
+
+ @Override
+ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) {
+ return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java
new file mode 100644
index 00000000..0b34cfa4
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.db.event;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "locker_sections")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class LockerSectionJpaEntity extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "locker_section_id")
+ private Long id;
+
+ @Column(nullable = false, length = 20)
+ private String label;
+
+ private LockerSectionJpaEntity(LockerSection section) {
+ this.id = section.getId();
+ this.label = section.getLabel();
+ }
+
+ public static LockerSectionJpaEntity from(LockerSection section) {
+ return new LockerSectionJpaEntity(section);
+ }
+
+ public LockerSection toDomain() {
+ return LockerSection.of(id, label);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java
new file mode 100644
index 00000000..284d78cd
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.db.event;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LockerSectionJpaRepository extends JpaRepository {
+
+ /**
+ * 표시 순서를 따로 두지 않으므로 식별자로 정렬해 같은 요청이 항상 같은 순서를 돌려주게 한다.
+ */
+ List findAllByOrderByIdAsc();
+}
diff --git a/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql
new file mode 100644
index 00000000..be03433f
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql
@@ -0,0 +1,38 @@
+-- 사물함 구역을 lockers.section 문자열에서 별도 테이블로 승격하고, 운영 회차에 게시 여부를 추가한다.
+--
+-- ⚠️ lockers에 운영 데이터가 없다는 전제다. 관리자 사물함 등록 API가 아직 없어 행이 쌓일 경로가 없고,
+-- locker_label은 관리자가 직접 입력하는 값이라 기존 행에서 유도할 수 없다.
+-- 적용 전 SELECT COUNT(*) FROM lockers 로 확인한다.
+
+CREATE TABLE locker_sections (
+ locker_section_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ label VARCHAR(20) NOT NULL, -- 화면에 표시할 구역 라벨. "A-1", "A-2", "C"
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+CREATE UNIQUE INDEX uk_locker_sections_label ON locker_sections (label);
+
+-- locker_number를 INT로 바꾼다. 사물함 번호는 블록 안에서 이어지는 순번이라(A-1이 38번까지면 A-2는 39번부터)
+-- 문자열로 두면 정렬이 사전순("1","10","2")이 되고 번호 범위를 다루기 어렵다.
+-- locker_label("A-37")은 번호에서 유도하지 않고 관리자가 직접 입력한다.
+ALTER TABLE lockers
+ ADD COLUMN section_id BIGINT NOT NULL,
+ ADD COLUMN locker_label VARCHAR(20) NOT NULL,
+ MODIFY COLUMN locker_number INT NOT NULL,
+ DROP COLUMN section;
+
+-- 구역 상세 조회: WHERE section_id = ? AND is_deleted = 0 ORDER BY row_no, column_no
+-- 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둬 filesort 없이 배치도 순서대로 읽는다.
+CREATE INDEX idx_lockers_section_id_is_deleted_row_no_column_no
+ ON lockers (section_id, is_deleted, row_no, column_no);
+
+-- 한 회차에 회원당 사물함 한 개만 신청할 수 있고 수정·취소가 없다. 신청 건수 제한을 DB 제약으로 못박는다.
+-- 취소가 없어 상태 컬럼이 없으므로 행 존재 자체가 신청이고, soft delete용 generated column 패턴도 필요 없다.
+-- 기존 idx_locker_applications_member_id는 유니크의 좌측 prefix가 아니라(회차가 앞) 회원 단독 조회에 여전히 쓰여 남긴다.
+CREATE UNIQUE INDEX uk_locker_applications_locker_period_id_member_id
+ ON locker_applications (locker_period_id, member_id);
+
+-- 운영진이 준비 중인 회차가 학생에게 노출되지 않도록 게시 여부를 둔다.
+-- events(V5)와 같은 방식이며, 관리자 게시 API가 생기기 전까지는 DB에서 직접 켜야 한다.
+ALTER TABLE locker_periods
+ ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 0;