diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java
new file mode 100644
index 00000000..37fe8ae5
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java
@@ -0,0 +1,50 @@
+package kr.ac.kookmin.stream.api.admin.event.locker;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.ac.kookmin.stream.api.admin.AdminApiUser;
+import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 운영진 사물함 구역 API의 문서 명세. 구현은 {@link AdminLockerSectionController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ *
+ * 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다.
+ */
+@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회")
+public interface AdminLockerSectionApi {
+
+ /** 구역별 전체·선택 가능·배정 사물함 수. */
+ @Operation(summary = "사물함 구역 목록 조회",
+ description = "운영 회차를 기준으로 구역별 전체 사물함 수, 선택 가능한 사물함 수, 배정된 사물함 수를 조회한다. "
+ + "사용 중지된 사물함과 그 회차에 배정된 사물함은 선택 가능 수에서 빠진다. "
+ + "배정 수는 배정된 뒤 사용 중지된 사물함도 센다. 구역은 식별자 오름차순으로 반환한다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
+ ApiResponse getLockerSections(
+ AdminApiUser apiUser,
+ @ParameterObject LockerPeriodParams params
+ );
+
+ /** 구역의 칸 배치·사진과 사물함별 상태. */
+ @Operation(summary = "사물함 구역 상세 조회",
+ description = "운영 회차를 기준으로 구역의 칸 배치 구조(layout), 구역 사진, 사물함별 상태를 조회한다. "
+ + "layout은 저장된 원문을 그대로 돌려주며 칸 번호는 lockers[].lockerNumber와 같다. "
+ + "배치 구조를 아직 등록하지 않은 구역이면 layout이 null이고 photos가 빈 배열이다. "
+ + "사물함 상태는 물리 상태와 그 회차의 배정 여부를 합친 값이고, 배정된 뒤 사용 중지된 칸은 ASSIGNED다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
+ ApiResponse getLockerSection(
+ AdminApiUser apiUser,
+ Long sectionId,
+ @ParameterObject LockerPeriodParams params
+ );
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java
new file mode 100644
index 00000000..a001e5fe
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java
@@ -0,0 +1,59 @@
+package kr.ac.kookmin.stream.api.admin.event.locker;
+
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import kr.ac.kookmin.stream.api.admin.AdminApiUser;
+import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
+import kr.ac.kookmin.stream.file.domain.File;
+import kr.ac.kookmin.stream.file.service.FileService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/lockers/sections")
+@RequiredArgsConstructor
+public class AdminLockerSectionController implements AdminLockerSectionApi {
+
+ private final LockerService lockerService;
+ private final FileService fileService;
+
+ @Override
+ @GetMapping
+ public ApiResponse getLockerSections(
+ AdminApiUser apiUser,
+ @Valid @ModelAttribute LockerPeriodParams params
+ ) {
+ List sections = lockerService.getSections(params.lockerPeriodId());
+ return ApiResponse.success(AdminLockerSectionListResponse.from(sections));
+ }
+
+ @Override
+ @GetMapping("/{sectionId}")
+ public ApiResponse getLockerSection(
+ AdminApiUser apiUser,
+ @PathVariable Long sectionId,
+ @Valid @ModelAttribute LockerPeriodParams params
+ ) {
+ Long lockerPeriodId = params.lockerPeriodId();
+ LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId);
+ // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다
+ Map photosById = fileService.findAllByIdIn(
+ detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds());
+ Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
+
+ return ApiResponse.success(
+ AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds));
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java
new file mode 100644
index 00000000..34e1a8b0
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java
@@ -0,0 +1,19 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.request;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다.
+ *
+ * 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가
+ * {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고,
+ * {@code @Valid @ModelAttribute}여야 400 {@code INVALID_INPUT}으로 나간다.
+ *
+ * @param lockerPeriodId 조회할 사물함 운영 회차 식별자
+ */
+public record LockerPeriodParams(
+
+ @NotNull(message = "사물함 운영 회차를 입력해 주세요.")
+ Long lockerPeriodId
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java
new file mode 100644
index 00000000..84389500
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java
@@ -0,0 +1,122 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.response;
+
+import com.fasterxml.jackson.annotation.JsonRawValue;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
+import kr.ac.kookmin.stream.file.domain.File;
+
+/**
+ * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
+ * @param photos 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다
+ */
+public record AdminLockerSectionDetailResponse(
+ Long sectionId,
+ String section,
+ Layout layout,
+ List photos,
+ List lockers
+) {
+
+ /**
+ * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다
+ * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
+ */
+ public static AdminLockerSectionDetailResponse of(
+ LockerSectionDetail detail,
+ Map photosById,
+ Set appliedLockerIds
+ ) {
+ return new AdminLockerSectionDetailResponse(
+ detail.section().getId(),
+ detail.section().getLabel(),
+ detail.layout() == null ? null : Layout.from(detail.layout()),
+ toPhotos(detail, photosById),
+ detail.lockers().stream()
+ .map(locker -> LockerItem.from(locker, appliedLockerIds.contains(locker.getId())))
+ .toList()
+ );
+ }
+
+ /** 배치 구조를 등록하지 않은 구역이면 사진도 없다. */
+ private static List toPhotos(LockerSectionDetail detail, Map photosById) {
+ if (detail.layout() == null) {
+ return List.of();
+ }
+ return detail.layout().getPhotoFileIds().stream()
+ .filter(photosById::containsKey)
+ .map(fileId -> Photo.from(fileId, photosById.get(fileId)))
+ .toList();
+ }
+
+ /**
+ * @param version layout 형식 버전
+ * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
+ */
+ public record Layout(
+ int version,
+ @JsonRawValue
+ @Schema(type = "object", description = "root 블록 트리. 칸 번호는 lockers[].lockerNumber와 같다")
+ String root
+ ) {
+
+ public static Layout from(LockerSectionLayout layout) {
+ return new Layout(layout.getVersion(), layout.getLayout());
+ }
+ }
+
+ /** 운영진은 사진을 지우거나 순서를 바꿀 때 파일 식별자가 필요하므로 URL과 함께 내려준다. */
+ public record Photo(Long fileId, String fileUrl) {
+
+ public static Photo from(Long fileId, File file) {
+ return new Photo(fileId, StorageUrlBuilder.build(file.getFileKey()));
+ }
+ }
+
+ /** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */
+ public record LockerItem(
+ Long lockerId,
+ int lockerNumber,
+ String lockerLabel,
+ LockerDisplayStatus status
+ ) {
+
+ /** @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */
+ public static LockerItem from(Locker locker, boolean applied) {
+ return new LockerItem(
+ locker.getId(),
+ locker.getLockerNumber(),
+ locker.getLockerLabel(),
+ LockerDisplayStatus.from(locker.getStatus(), applied)
+ );
+ }
+ }
+
+ /**
+ * 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라
+ * 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절).
+ */
+ public enum LockerDisplayStatus {
+
+ AVAILABLE,
+ DISABLED,
+ ASSIGNED;
+
+ /**
+ * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진이 그 학생에게 다른 자리를 줘야 하는 상황이라
+ * 배정 사실이 물리 상태보다 먼저 보여야 한다.
+ */
+ public static LockerDisplayStatus from(LockerStatus status, boolean applied) {
+ if (applied) {
+ return ASSIGNED;
+ }
+ return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED;
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java
new file mode 100644
index 00000000..957fdf17
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java
@@ -0,0 +1,37 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.response;
+
+import java.util.List;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
+
+public record AdminLockerSectionListResponse(List sections) {
+
+ public static AdminLockerSectionListResponse from(List summaries) {
+ return new AdminLockerSectionListResponse(summaries.stream()
+ .map(Section::from)
+ .toList());
+ }
+
+ /**
+ * @param totalCount 구역 내 전체 사물함 수. 사용 중지된 사물함도 포함한다
+ * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다
+ * @param assignedCount 이 회차에 배정된 사물함 수. 배정된 뒤 사용 중지된 사물함도 센다
+ */
+ public record Section(
+ Long sectionId,
+ String section,
+ int totalCount,
+ int availableCount,
+ int assignedCount
+ ) {
+
+ public static Section from(LockerSectionSummary summary) {
+ return new Section(
+ summary.sectionId(),
+ summary.label(),
+ summary.totalCount(),
+ summary.availableCount(),
+ summary.assignedCount()
+ );
+ }
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
index cf75b921..656cc7a8 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
@@ -39,8 +39,8 @@ ApiResponse getSections(
@Operation(summary = "사물함 구역 상세 조회",
description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. "
+ "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 "
- + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 "
- + "선택할 수 있다.")
+ + "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. "
+ + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse getSectionLockers(
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java
index 19b752d2..454e54d5 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java
@@ -3,7 +3,7 @@
import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.List;
-import java.util.Optional;
+import java.util.Map;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerApplyRequest;
@@ -70,15 +70,14 @@ public ApiResponse getSectionLockers(
Long lockerPeriodId = params.lockerPeriodId();
LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId);
// 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다
- File photo = Optional.ofNullable(detail.layout())
- .flatMap(layout -> fileService.findById(layout.getPhotoFileId()))
- .orElse(null);
+ Map photosById = fileService.findAllByIdIn(
+ detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds());
Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getId)
.orElse(null);
- return ApiResponse.success(LockerSectionDetailResponse.of(detail, photo, appliedLockerIds, myLockerId));
+ return ApiResponse.success(LockerSectionDetailResponse.of(detail, photosById, appliedLockerIds, myLockerId));
}
@Override
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
index a8868a75..ecff655e 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
@@ -3,6 +3,8 @@
import com.fasterxml.jackson.annotation.JsonRawValue;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
@@ -10,25 +12,25 @@
import kr.ac.kookmin.stream.file.domain.File;
/**
- * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
- * @param photoUrl 구역 실제 사진. 배치 구조를 아직 등록하지 않았거나 사진 파일이 삭제됐으면 {@code null}
+ * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
+ * @param photoUrls 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다
*/
public record LockerSectionDetailResponse(
Long sectionId,
String section,
Layout layout,
- String photoUrl,
+ List photoUrls,
List lockers
) {
/**
- * @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null}
+ * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerSectionDetailResponse of(
LockerSectionDetail detail,
- File photo,
+ Map photosById,
Set appliedLockerIds,
Long myLockerId
) {
@@ -36,13 +38,26 @@ public static LockerSectionDetailResponse of(
detail.section().getId(),
detail.section().getLabel(),
detail.layout() == null ? null : Layout.from(detail.layout()),
- StorageUrlBuilder.build(photo),
+ toPhotoUrls(detail, photosById),
detail.lockers().stream()
.map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
.toList()
);
}
+ /**
+ * 배치 구조를 등록하지 않은 구역이면 사진도 없다. 학생 앱은 사진을 보여주기만 하므로 파일 식별자 없이 URL만 담는다.
+ */
+ private static List toPhotoUrls(LockerSectionDetail detail, Map photosById) {
+ if (detail.layout() == null) {
+ return List.of();
+ }
+ return detail.layout().getPhotoFileIds().stream()
+ .map(fileId -> StorageUrlBuilder.build(fileId, photosById))
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
/**
* @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
index 83632b71..f8cc7002 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;
+import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
@@ -22,9 +23,16 @@ public class LockerSectionLayout {
private String layout;
/** layout 형식 버전. 블록 종류·필드가 바뀌면 올려서 옛 앱이 모르는 형식을 알아채게 한다. */
private int version;
- private Long photoFileId;
+ /** 구역 실제 사진. 배열 순서가 노출 순서다. 등록하지 않았으면 빈 목록이다. */
+ private List photoFileIds;
- public static LockerSectionLayout of(Long id, Long sectionId, String layout, int version, Long photoFileId) {
- return new LockerSectionLayout(id, sectionId, layout, version, photoFileId);
+ public static LockerSectionLayout of(
+ Long id,
+ Long sectionId,
+ String layout,
+ int version,
+ List photoFileIds
+ ) {
+ return new LockerSectionLayout(id, sectionId, layout, version, photoFileIds);
}
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java
index ddc68180..64891e90 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java
@@ -1,13 +1,18 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;
/**
- * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다.
+ * 사물함 구역 목록 한 건. 세 개수 모두 저장값이 아니라 조회 시점에 센다.
+ *
+ * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다
+ * @param totalCount 구역의 전체 사물함 수. 사용 중지된 사물함도 포함한다
+ * @param assignedCount 이 회차에 배정된 사물함 수
*/
public record LockerSectionSummary(
Long sectionId,
String label,
int availableCount,
int totalCount,
+ int assignedCount,
SectionAvailabilityStatus availabilityStatus
) {
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java
index 02c82ecf..e0dcdbab 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java
@@ -89,12 +89,17 @@ private LockerSectionSummary summarize(
int availableCount = (int) lockers.stream()
.filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId())))
.count();
+ // 사용 중지된 사물함도 배정돼 있으면 센다. 배정 후 고장 난 칸이라 운영진이 다른 자리를 줘야 하는 대상이다
+ int assignedCount = (int) lockers.stream()
+ .filter(locker -> appliedLockerIds.contains(locker.getId()))
+ .count();
return new LockerSectionSummary(
section.getId(),
section.getLabel(),
availableCount,
totalCount,
+ assignedCount,
SectionAvailabilityStatus.from(availableCount, totalCount)
);
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
index 6689bd42..d3407e85 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
@@ -6,6 +6,7 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
+import java.util.List;
import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
import lombok.AccessLevel;
@@ -35,15 +36,17 @@ public class LockerSectionLayoutJpaEntity extends BaseTimeEntity {
@Column(nullable = false, columnDefinition = "SMALLINT")
private int version;
- @Column(name = "photo_file_id", nullable = false)
- private Long photoFileId;
+ /** 구역 실제 사진. 배열 순서가 노출 순서다. 파일 목록을 JSON으로 두는 건 notices.image_ids와 같은 방식이다. */
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "photo_file_ids", columnDefinition = "json")
+ private List photoFileIds;
private LockerSectionLayoutJpaEntity(LockerSectionLayout sectionLayout) {
this.id = sectionLayout.getId();
this.sectionId = sectionLayout.getSectionId();
this.layout = sectionLayout.getLayout();
this.version = sectionLayout.getVersion();
- this.photoFileId = sectionLayout.getPhotoFileId();
+ this.photoFileIds = sectionLayout.getPhotoFileIds();
}
public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayout) {
@@ -51,6 +54,7 @@ public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayou
}
public LockerSectionLayout toDomain() {
- return LockerSectionLayout.of(id, sectionId, layout, version, photoFileId);
+ // 사진을 한 장도 등록하지 않은 구역은 컬럼이 NULL이다. 읽는 쪽이 매번 null을 보지 않도록 빈 목록으로 바꾼다
+ return LockerSectionLayout.of(id, sectionId, layout, version, photoFileIds == null ? List.of() : photoFileIds);
}
}
diff --git a/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql
new file mode 100644
index 00000000..412e4993
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql
@@ -0,0 +1,14 @@
+-- 구역 사진을 여러 장 저장한다. 구역당 한 장 전제로 V15에서 단일 컬럼을 뒀는데, 실제로 두 장인 구역이 있다.
+-- 배열 순서가 노출 순서다.
+--
+-- 저장소의 다른 파일 목록 컬럼(notices.image_ids·attachment_ids, events.image_ids, archives.image_ids)과 같은
+-- JSON NULL로 둔다. JSON으로 바꾸면 "반드시 한 장"은 DB가 못 지킨다 — 빈 배열은 NULL이 아니라서 NOT NULL을
+-- 통과한다. 최소 한 장 검증은 구역 쓰기 API에서 한다.
+ALTER TABLE locker_section_layouts
+ ADD COLUMN photo_file_ids JSON NULL;
+
+UPDATE locker_section_layouts
+SET photo_file_ids = JSON_ARRAY(photo_file_id);
+
+ALTER TABLE locker_section_layouts
+ DROP COLUMN photo_file_id;