diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java
new file mode 100644
index 00000000..410776d2
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationApi.java
@@ -0,0 +1,60 @@
+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.LockerAssignRequest;
+import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 운영진 사물함 신청 API의 문서 명세. 구현은 {@link AdminLockerApplicationController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ */
+@Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회·취소·수동 배정")
+public interface AdminLockerApplicationApi {
+
+ /** 신청자 이름·학번과 배정된 칸. 취소된 신청도 담는다. */
+ @Operation(summary = "사물함 신청 현황 조회",
+ description = "운영 회차의 신청을 신청 일시 최신순으로 조회한다. 이름·학번 일부를 keyword로 넘기면 그 회원만 본다. "
+ + "운영진이 취소한 신청도 담기며 canceled로 구분한다. 학생 화면에서는 취소된 신청이 보이지 않는다. "
+ + "탈퇴한 회원의 지난 신청은 name·studentId가 null이고, 철거된 사물함은 lockerLabel이 null이다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
+ ApiResponse> getApplications(
+ AdminApiUser apiUser,
+ @ParameterObject LockerPeriodParams params,
+ String keyword,
+ @ParameterObject PageParams pageParams
+ );
+
+ @Operation(summary = "사물함 신청 취소",
+ description = "운영진이 신청을 취소한다. 행은 남기고 취소 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있다. "
+ + "학생 화면에서는 그 신청이 사라져 미신청으로 보이고, 취소됐다는 사실은 알림으로 전달한다. "
+ + "사용 기간이 끝난 회차의 신청은 되돌릴 자리가 없어 거부한다.")
+ @ApiErrorCode(type = LockerErrorCode.class,
+ codes = {"LOCKER_APPLICATION_NOT_FOUND", "LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED",
+ "LOCKER_APPLICATION_ALREADY_CANCELED"})
+ ApiResponse cancelApplication(AdminApiUser apiUser, Long lockerApplicationId);
+
+ @Operation(summary = "사물함 수동 배정",
+ description = "운영진이 학생회비 납부자에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓쳤거나 "
+ + "자리를 옮겨야 하는 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. "
+ + "사용 기간이 끝난 회차와 이미 신청된 칸은 거부하고, 한 학생이 한 회차에 두 칸을 가질 수 없다. "
+ + "응답 본문이 없으므로 결과는 신청 현황을 다시 조회해 받는다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"})
+ @ApiErrorCode(type = LockerErrorCode.class,
+ codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED", "LOCKER_NOT_IN_SECTION",
+ "LOCKER_ALREADY_ASSIGNED", "MEMBER_ALREADY_HAS_LOCKER"})
+ ApiResponse assignLocker(AdminApiUser apiUser, LockerAssignRequest request);
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java
new file mode 100644
index 00000000..0174ae14
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerApplicationController.java
@@ -0,0 +1,64 @@
+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.LockerAssignRequest;
+import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse;
+import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerApplicationSearchUseCase;
+import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerAssignUseCase;
+import kr.ac.kookmin.stream.api.admin.event.locker.usecase.MemberLockerApplication;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/lockers/applications")
+@RequiredArgsConstructor
+public class AdminLockerApplicationController implements AdminLockerApplicationApi {
+
+ private final AdminLockerApplicationSearchUseCase adminLockerApplicationSearchUseCase;
+ private final AdminLockerAssignUseCase adminLockerAssignUseCase;
+ private final AdminLockerService adminLockerService;
+
+ @Override
+ @GetMapping
+ public ApiResponse> getApplications(
+ AdminApiUser apiUser,
+ @Valid @ModelAttribute LockerPeriodParams params,
+ @RequestParam(required = false) String keyword,
+ @Valid @ModelAttribute PageParams pageParams
+ ) {
+ PageResult result = adminLockerApplicationSearchUseCase.search(
+ params.lockerPeriodId(), keyword, pageParams.toOffset());
+
+ return ApiResponse.success(PageResponse.from(result, vo -> AdminLockerApplicationSearchResponse.of(
+ vo.result().application(), vo.result().locker(), vo.member())));
+ }
+
+ @Override
+ @DeleteMapping("/{lockerApplicationId}")
+ public ApiResponse cancelApplication(AdminApiUser apiUser, @PathVariable Long lockerApplicationId) {
+ adminLockerService.cancelApplication(lockerApplicationId);
+ return ApiResponse.success();
+ }
+
+ @Override
+ @PostMapping
+ public ApiResponse assignLocker(AdminApiUser apiUser, @Valid @RequestBody LockerAssignRequest request) {
+ adminLockerAssignUseCase.assign(request.lockerPeriodId(), request.memberId(), request.lockerId());
+ return ApiResponse.success();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/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..4b2388f1
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java
@@ -0,0 +1,69 @@
+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.request.LockerStatusChangeRequest;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
+import kr.ac.kookmin.stream.api.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
+ );
+
+ /** 구역 안의 사물함 여러 개를 같은 상태로 바꾼다. */
+ @Operation(summary = "사물함 상태 일괄 변경",
+ description = "구역 안의 사물함 여러 개를 AVAILABLE(선택가능) 또는 DISABLED(선택불가)로 한 번에 바꾼다. "
+ + "하나라도 바꿀 수 없으면 아무것도 바뀌지 않는다. "
+ + "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, "
+ + "AVAILABLE로 되돌리는 것은 막지 않는다. "
+ + "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다. "
+ + "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다. "
+ + "이용 중 검사와 학생 신청이 겹치면 서로 다른 테이블에 써서 둘 다 성공할 수 있고, "
+ + "그때는 배정된 칸이 사용 중지된 채로 남는다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"})
+ ApiResponse changeLockerStatus(
+ AdminApiUser apiUser,
+ Long sectionId,
+ LockerStatusChangeRequest request
+ );
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java
new file mode 100644
index 00000000..1cbd5a8d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java
@@ -0,0 +1,73 @@
+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.request.LockerStatusChangeRequest;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
+import kr.ac.kookmin.stream.api.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.AdminLockerService;
+import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
+import kr.ac.kookmin.stream.file.domain.File;
+import kr.ac.kookmin.stream.file.service.FileService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/lockers/sections")
+@RequiredArgsConstructor
+public class AdminLockerSectionController implements AdminLockerSectionApi {
+
+ private final LockerService lockerService;
+ private final AdminLockerService adminLockerService;
+ 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.photoFileIds());
+ Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
+
+ return ApiResponse.success(
+ AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds));
+ }
+
+ @Override
+ @PatchMapping("/{sectionId}/lockers/status")
+ public ApiResponse changeLockerStatus(
+ AdminApiUser apiUser,
+ @PathVariable Long sectionId,
+ @Valid @RequestBody LockerStatusChangeRequest request
+ ) {
+ adminLockerService.changeLockerStatus(sectionId, request.distinctLockerIds(), request.lockerStatus());
+ return ApiResponse.success();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java
new file mode 100644
index 00000000..874faa2d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerAssignRequest.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.request;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 운영진이 학생에게 사물함을 직접 배정한다.
+ *
+ * 회원은 식별자로 받는다. 운영진 화면이 학생회비 납부자 목록을 이름·학번으로 검색해 고르는 구조라, 거기서 받은
+ * 식별자를 그대로 넘기면 된다.
+ */
+public record LockerAssignRequest(
+
+ @NotNull(message = "사물함 운영 회차를 입력해 주세요.")
+ Long lockerPeriodId,
+
+ @NotNull(message = "배정할 학생을 선택해 주세요.")
+ Long memberId,
+
+ @NotNull(message = "배정할 사물함을 선택해 주세요.")
+ Long lockerId
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/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..a1d40038
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java
@@ -0,0 +1,20 @@
+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/request/LockerStatusChangeRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java
new file mode 100644
index 00000000..f3d79138
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerStatusChangeRequest.java
@@ -0,0 +1,28 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.request;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
+
+/**
+ * 구역 안의 사물함 여러 개를 같은 상태로 바꾼다.
+ *
+ * 구역 상세가 내려주는 {@code ASSIGNED}는 신청으로 생기는 표시용 값이라 이 경로로는 바꿀 수 없다.
+ */
+public record LockerStatusChangeRequest(
+ @NotEmpty(message = "사물함을 하나 이상 선택해 주세요.")
+ List<@NotNull(message = "사물함 ID는 비워 둘 수 없습니다.") Long> lockerIds,
+
+ @NotNull(message = "변경할 상태를 입력해 주세요.")
+ LockerStatus lockerStatus
+) {
+
+ /**
+ * 중복을 걷어낸 사물함 식별자. 같은 칸이 여러 번 와도 결과는 같지만, 그대로 두면 이용 중 검사의 {@code IN} 절이
+ * 구역의 칸 수보다 커진다. 중복을 없애면 상한이 그 구역의 칸 수가 되어 따로 숫자를 둘 필요가 없다.
+ */
+ public List distinctLockerIds() {
+ return lockerIds.stream().distinct().toList();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java
new file mode 100644
index 00000000..e527f4ef
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerApplicationSearchResponse.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.response;
+
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+/**
+ * 운영진 신청 현황의 한 줄.
+ *
+ * @param name 신청자 이름. 탈퇴한 회원의 지난 신청이면 {@code null}
+ * @param studentId 신청자 학번. 탈퇴한 회원의 지난 신청이면 {@code null}
+ * @param lockerLabel 배정된 사물함 이름. 철거된 사물함도 이름은 보여준다
+ * @param canceled 운영진이 취소했는지. 기간이 끝났는지는 회차의 사용 종료일로 화면이 가른다
+ */
+public record AdminLockerApplicationSearchResponse(
+ Long lockerApplicationId,
+ Long memberId,
+ String name,
+ String studentId,
+ String lockerLabel,
+ LocalDateTime appliedAt,
+ boolean canceled
+) {
+
+ public static AdminLockerApplicationSearchResponse of(
+ LockerApplication application, Locker locker, Member member
+ ) {
+ return new AdminLockerApplicationSearchResponse(
+ application.getId(),
+ application.getMemberId(),
+ member == null ? null : member.getName(),
+ member == null ? null : member.getStudentId(),
+ locker == null ? null : locker.getLockerLabel(),
+ application.getAppliedAt(),
+ application.isCanceled()
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/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..c4c2482a
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java
@@ -0,0 +1,119 @@
+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.of(locker, appliedLockerIds.contains(locker.getId())))
+ .toList()
+ );
+ }
+
+ private static List toPhotos(LockerSectionDetail detail, Map photosById) {
+ return detail.photoFileIds().stream()
+ .filter(photosById::containsKey)
+ .map(fileId -> Photo.of(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 of(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 of(Locker locker, boolean applied) {
+ return new LockerItem(
+ locker.getId(),
+ locker.getLockerNumber(),
+ locker.getLockerLabel(),
+ LockerDisplayStatus.from(locker, applied)
+ );
+ }
+ }
+
+ /**
+ * 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라
+ * 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절).
+ */
+ public enum LockerDisplayStatus {
+
+ AVAILABLE,
+ DISABLED,
+ ASSIGNED;
+
+ /**
+ * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진 화면이 칸 하나를 색 하나로 그려서 둘 중
+ * 하나만 고를 수 있고, 배정된 학생이 있다는 사실을 먼저 보여야 한다.
+ */
+ public static LockerDisplayStatus from(Locker locker, boolean applied) {
+ if (applied) {
+ return ASSIGNED;
+ }
+ // 구역 목록의 availableCount와 같은 기준으로 가른다. 배정 여부는 위에서 이미 갈랐으므로 false를 넘긴다
+ return locker.isSelectable(false) ? 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/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java
new file mode 100644
index 00000000..7d070384
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerApplicationSearchUseCase.java
@@ -0,0 +1,50 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.usecase;
+
+import java.util.List;
+import java.util.Map;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
+import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class AdminLockerApplicationSearchUseCase {
+
+ private final AdminLockerService adminLockerService;
+ private final MemberService memberService;
+
+ /**
+ * 운영 회차의 신청 현황에 신청자 정보를 붙인다. 취소된 신청도 담는다.
+ *
+ * @param keyword 이름·학번 일부. 비어 있으면 회차 전체를 본다
+ */
+ public PageResult search(Long lockerPeriodId, String keyword, PageOffset pageOffset) {
+ List memberIds = null;
+
+ if (keyword != null && !keyword.isBlank()) {
+ memberIds = memberService.searchIdsByKeyword(keyword);
+
+ if (memberIds.isEmpty()) {
+ return PageResult.empty(pageOffset.page(), pageOffset.size());
+ }
+ }
+
+ PageResult result =
+ adminLockerService.getApplications(lockerPeriodId, memberIds, pageOffset);
+ List resultMemberIds = result.content().stream()
+ .map(applicationResult -> applicationResult.application().getMemberId())
+ .toList();
+
+ // 탈퇴(소프트 삭제)한 회원은 getMapByIds에서 빠지므로, 그 회원의 지난 신청은 member가 null로 남는다
+ Map memberMap = memberService.getMapByIds(resultMemberIds);
+
+ return result.map(applicationResult -> new MemberLockerApplication(
+ applicationResult, memberMap.getOrDefault(applicationResult.application().getMemberId(), null)));
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java
new file mode 100644
index 00000000..9c514d46
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/AdminLockerAssignUseCase.java
@@ -0,0 +1,29 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.usecase;
+
+import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
+import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class AdminLockerAssignUseCase {
+
+ private final PayerService payerService;
+ private final AdminLockerService adminLockerService;
+
+ /**
+ * 학생회비 납부자인지 확인하고 사물함을 배정한다. 운영진 화면이 납부자 목록에서 학생을 고르게 돼 있어 같은 기준을
+ * 서버에서도 지킨다.
+ *
+ * 납부 여부는 회비 도메인이, 배정은 사물함 도메인이 안다. 도메인끼리 부르지 않고 여기서 묶는다
+ * ({@code architecture.md} 6-1절).
+ *
+ * 트랜잭션을 걸지 않는다. 납부자 확인은 읽기이고 그 전에 쓰는 것이 없어, 실패해도 되돌릴 것이 없다.
+ * 쓰기는 {@code assignLocker}가 자기 트랜잭션에서 한다.
+ */
+ public void assign(Long lockerPeriodId, Long memberId, Long lockerId) {
+ payerService.validatePayer(memberId);
+ adminLockerService.assignLocker(lockerPeriodId, memberId, lockerId);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java
new file mode 100644
index 00000000..1bfde3a6
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/usecase/MemberLockerApplication.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.admin.event.locker.usecase;
+
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+// AdminLockerApplicationSearchUseCase가 조합한 결과. DTO 변환은 Controller가 한다
+// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함).
+// member는 탈퇴(소프트 삭제)한 회원의 지난 신청이면 null일 수 있다.
+public record MemberLockerApplication(LockerApplicationResult result, Member member) {
+}
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 8663bd51..d3a6f8c3 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java
@@ -27,7 +27,8 @@ public interface AppLockerApi {
/** 구역별 전체·선택 가능 사물함 수와 표시 상태. */
@Operation(summary = "사물함 구역 목록 조회",
description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. "
- + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.")
+ + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다. "
+ + "선택 가능 수는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 LOCKER_APPLY_NOT_OPEN이 난다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
ApiResponse getSections(
@@ -39,8 +40,9 @@ ApiResponse getSections(
@Operation(summary = "사물함 구역 상세 조회",
description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. "
+ "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 "
- + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 "
- + "선택할 수 있다.")
+ + "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. "
+ + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다. "
+ + "선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 LOCKER_APPLY_NOT_OPEN이 난다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse getSectionLockers(
@@ -51,10 +53,12 @@ ApiResponse getSectionLockers(
/** 고른 사물함을 신청하고 즉시 배정한다. */
@Operation(summary = "사물함 신청",
- description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 운영 회차당 한 개만 신청할 수 있고 "
- + "신청 후에는 취소하거나 변경할 수 없다. 같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.")
+ description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 모집 기간 안에서만 받는다. "
+ + "운영 회차당 한 개만 신청할 수 있고 신청 후에는 직접 취소하거나 변경할 수 없다. "
+ + "같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_ALREADY_ASSIGNED"})
+ @ApiErrorCode(type = LockerErrorCode.class,
+ codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_APPLY_NOT_OPEN", "LOCKER_ALREADY_ASSIGNED"})
ApiResponse apply(AppApiUser apiUser, LockerApplyRequest request);
/** 내 사물함 신청 내역. */
diff --git a/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..f2f9d883 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;
@@ -69,16 +69,13 @@ 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.photoFileIds());
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..3300b7cd 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,7 @@
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.LockerSectionDetail;
@@ -10,25 +11,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 +37,23 @@ 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) {
+ return detail.photoFileIds().stream()
+ .filter(photosById::containsKey)
+ .map(fileId -> StorageUrlBuilder.build(fileId, photosById))
+ .toList();
+ }
+
/**
* @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java
new file mode 100644
index 00000000..eeffdbb0
--- /dev/null
+++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignedByAdmin.java
@@ -0,0 +1,4 @@
+package kr.ac.kookmin.stream.common.event;
+
+/** 운영진이 회원에게 사물함을 직접 배정했다. 회원이 신청한 적 없는 배정이라 {@link LockerAssigned}와 문구가 다르다. */
+public record LockerAssignedByAdmin(Long memberId, Long lockerPeriodId) implements DomainEvent {}
diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java
new file mode 100644
index 00000000..b9b5f267
--- /dev/null
+++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerAssignmentCanceled.java
@@ -0,0 +1,4 @@
+package kr.ac.kookmin.stream.common.event;
+
+/** 운영진이 회원의 사물함 배정을 취소했다. 회원 화면에서는 신청이 사라지므로 알림이 유일한 전달 수단이다. */
+public record LockerAssignmentCanceled(Long memberId, Long lockerPeriodId) implements DomainEvent {}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
index 552dc6f8..1df0b67b 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java
@@ -40,6 +40,13 @@ public static Locker of(
* @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
*/
public boolean isSelectable(boolean applied) {
- return status == LockerStatus.AVAILABLE && !applied;
+ return status.isUsable() && !applied;
+ }
+
+ /**
+ * 사물함 자체 상태를 바꾼다. 이미 그 상태여도 그대로 둔다 — 영속성 컨텍스트가 값이 바뀐 행만 UPDATE한다.
+ */
+ public void changeStatus(LockerStatus status) {
+ this.status = status;
}
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java
index d5dbaf34..c9e3f1ca 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;
import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
@@ -16,15 +17,18 @@ public class LockerApplication {
private Long memberId;
private Long lockerId;
private LocalDateTime appliedAt;
+ /** 운영진이 취소했는지. 취소해도 행은 남기므로 살아 있는 신청인지는 이 값으로 가른다. */
+ private boolean canceled;
public static LockerApplication of(
Long id,
Long lockerPeriodId,
Long memberId,
Long lockerId,
- LocalDateTime appliedAt
+ LocalDateTime appliedAt,
+ boolean canceled
) {
- return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt);
+ return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt, canceled);
}
/**
@@ -33,6 +37,17 @@ public static LockerApplication of(
* @param appliedAt 신청 시각. 클라이언트가 보내지 않고 서버 시각으로 기록한다
*/
public static LockerApplication create(Long lockerPeriodId, Long memberId, Long lockerId, LocalDateTime appliedAt) {
- return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt);
+ return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt, false);
+ }
+
+ /**
+ * 운영진이 신청을 취소한다. 행은 남기고 표시만 바꾼다 — 운영진이 그 칸이 왜 비었는지 알아야 하고,
+ * 같은 학생·같은 칸에 다시 배정할 수 있어야 한다.
+ */
+ public void cancel() {
+ if (canceled) {
+ throw new BusinessException(LockerErrorCode.LOCKER_APPLICATION_ALREADY_CANCELED);
+ }
+ this.canceled = true;
}
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java
index e8c1850a..95bae6a7 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java
@@ -19,7 +19,13 @@ public enum LockerErrorCode implements ErrorCode {
LOCKER_PERIOD_ALREADY_ENDED(ErrorStatus.CONFLICT, "종료된 회차는 수정할 수 없습니다."),
LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."),
LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."),
- LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다.");
+ LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."),
+ LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."),
+ LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."),
+ LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."),
+ MEMBER_ALREADY_HAS_LOCKER(ErrorStatus.CONFLICT, "이미 이 회차에 사물함을 배정받은 학생입니다."),
+ LOCKER_APPLICATION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 신청을 찾을 수 없습니다."),
+ LOCKER_APPLICATION_ALREADY_CANCELED(ErrorStatus.CONFLICT, "이미 취소된 신청입니다.");
private final int status;
private final String message;
diff --git a/core/domain/event/src/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 3b3d74ac..f9d08822 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java
@@ -3,6 +3,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
+import java.time.temporal.ChronoUnit;
import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@@ -92,6 +93,24 @@ public LockerPeriodStatus calculateStatus(LocalDateTime now) {
return LockerPeriodStatus.IN_PROGRESS;
}
+ /**
+ * 지금이 신청을 받는 기간인지. 신청 시작 일시부터 마감 일시까지 양 끝을 포함한다.
+ *
+ * 사용 시작일과의 선후는 보지 않는다. 사용을 시작한 뒤 남은 자리를 추가 모집하려고 마감 일시를 늘리는 운영이 있어
+ * 두 기간이 겹칠 수 있다.
+ *
+ * 사용이 끝난 회차는 받지 않는다. {@link #validateDates}가 마감일을 사용 종료일 안으로 묶어 두므로 정상 등록된
+ * 회차에서는 이 조건이 걸리지 않지만, 관리자 API가 없던 시기에 DB로 직접 넣은 회차(V15·V20 주석)까지 막는다.
+ *
+ * @param now 판단 기준 시각. 마감 일시는 초까지만 저장되므로({@link #toApplyEndAt}) 초 단위로 끊어 견준다.
+ * 그러지 않으면 마감일 23:59:59.001부터는 나노초 때문에 마감으로 보인다
+ */
+ public boolean isApplyOpen(LocalDateTime now) {
+ LocalDateTime at = now.truncatedTo(ChronoUnit.SECONDS);
+ return !at.isBefore(applyStartAt) && !at.isAfter(applyEndAt)
+ && !isUsageEnded(at.toLocalDate());
+ }
+
/**
* 회차 범위(신청 시작일 ~ 사용 종료일)가 다른 회차와 겹치는지. 회차는 같은 시기에 둘이 돌 수 없다.
*/
@@ -124,7 +143,7 @@ private static String toName(int year, int semester) {
* 운영이 있어 두 기간이 겹칠 수 있다.
*
* 다만 신청 마감일은 사용 종료일을 넘을 수 없다. 넘기면 {@link #calculateStatus}가 종료로 보는 뒤에도
- * 신청 마감일이 남아 있고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다.
+ * {@link #isApplyOpen}이 신청을 받고, {@link #overlaps}가 보는 회차 범위도 신청 기간을 다 담지 못한다.
*/
private static void validateDates(
LocalDate applyStartDate,
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java
index 08c4d151..319dca16 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java
@@ -13,4 +13,9 @@ public record LockerSectionDetail(
LockerSectionLayout layout,
List lockers
) {
+
+ /** 구역 사진 파일 식별자를 노출 순서대로. 배치 구조를 아직 등록하지 않은 구역이면 사진도 없어 빈 목록이다. */
+ public List photoFileIds() {
+ return layout == null ? List.of() : layout.getPhotoFileIds();
+ }
}
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/domain/LockerStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java
index adba7ae9..a76f8d6e 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerStatus.java
@@ -1,6 +1,16 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;
+/** 사물함 자체의 물리 상태. 회차 배정 여부는 담지 않는다. */
public enum LockerStatus {
+
AVAILABLE,
- DISABLED
+ DISABLED;
+
+ /**
+ * 학생에게 내줄 수 있는 상태인지. 쓸 수 있는 상태를 허용 목록으로 두어, 나중에 상태가 늘어도 그 값이 자동으로
+ * "쓸 수 없음"에 들어가게 한다. 상태를 차단 목록으로 보는 곳이 생기면 새 값만 조용히 빠져나간다.
+ */
+ public boolean isUsable() {
+ return this == AVAILABLE;
+ }
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java
index 6ee3936d..f5b6d4b0 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java
@@ -1,8 +1,11 @@
package kr.ac.kookmin.stream.event.domain.locker.repository;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
/**
@@ -26,20 +29,37 @@ public interface LockerApplicationRepository {
Set findAppliedLockerIds(Long lockerPeriodId);
/**
- * 해당 운영 회차에 신청한 회원 식별자. 취소가 없어 신청 행이 곧 사물함을 가진 회원이다.
+ * 해당 운영 회차에 사물함을 가진 회원 식별자. 취소된 신청은 뺀다.
*/
List findMemberIds(Long lockerPeriodId);
/**
- * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다.
+ * 해당 운영 회차에서 회원이 쓰고 있는 사물함 식별자. 취소되지 않은 신청은 회차당 한 건까지라 최대 하나다.
*/
Optional findAppliedLockerId(Long lockerPeriodId, Long memberId);
+ /**
+ * 해당 회차들에 이 사물함 중 하나라도 신청됐는지. (회차, 사물함) 유니크 인덱스를 그대로 탄다.
+ */
+ boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds);
+
/**
* 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다.
*/
List findByMemberId(Long memberId);
+ /**
+ * 신청 한 건. 취소된 신청도 돌려준다 — 운영진이 취소 여부를 보고 판단한다.
+ */
+ Optional findById(Long lockerApplicationId);
+
+ /**
+ * 운영 회차의 신청을 신청 일시 최신순으로 조회한다. 운영진 화면이 쓰므로 취소된 신청도 담는다.
+ *
+ * @param memberIds 회원으로 좁힐 때만 넘긴다. {@code null}이면 회차 전체를 본다
+ */
+ PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset);
+
/**
* 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면
* {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다.
diff --git a/core/domain/event/src/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 150da354..c75f15af 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java
@@ -92,4 +92,14 @@ public interface LockerRepository {
* 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다.
*/
List findLockersByIdsIncludingDeleted(Collection lockerIds);
+
+ /**
+ * 식별자에 해당하는 사물함. 삭제된 사물함은 없는 것으로 본다. 순서는 보장하지 않는다.
+ */
+ List findLockersByIds(Collection lockerIds);
+
+ /**
+ * 사물함 여러 건을 저장한다. 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다.
+ */
+ void saveLockers(List lockers);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java
index 65ba2420..b188e33d 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/AdminLockerService.java
@@ -1,9 +1,13 @@
package kr.ac.kookmin.stream.event.domain.locker.service;
import java.util.List;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
/**
* 운영진 사물함 API가 쓰는 서비스.
@@ -36,4 +40,34 @@ public interface AdminLockerService {
* 운영 회차를 소프트 삭제한다. 신청이 한 건이라도 있으면 거부한다.
*/
void deletePeriod(Long lockerPeriodId);
+
+ /**
+ * 구역 안의 사물함 여러 개의 상태를 한 번에 바꾼다.
+ *
+ * 하나라도 바꿀 수 없으면 아무것도 바꾸지 않는다. 사용 기간이 끝나지 않은 회차에 배정된 사물함은
+ * 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다.
+ */
+ void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status);
+
+ /**
+ * 운영 회차의 신청 현황을 신청 일시 최신순으로 조회한다. 취소된 신청도 담는다.
+ *
+ * @param memberIds 회원으로 좁힐 때만 넘긴다. {@code null}이면 회차 전체를 본다
+ */
+ PageResult getApplications(Long lockerPeriodId, List memberIds, PageOffset pageOffset);
+
+ /**
+ * 신청을 취소한다. 행은 남기고 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있게 한다.
+ *
+ * 사용 기간이 끝난 회차의 신청은 되돌릴 자리가 없어 거부한다.
+ */
+ void cancelApplication(Long lockerApplicationId);
+
+ /**
+ * 운영진이 학생에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓친 학생이나 자리를 옮겨야 하는
+ * 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. 사용 기간이 끝난 회차만 막는다.
+ *
+ * 학생회비 납부자인지는 이 서비스가 보지 않는다. 교차 도메인이라 {@code api:admin-api}의 UseCase가 확인한다.
+ */
+ void assignLocker(Long lockerPeriodId, Long memberId, Long lockerId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java
index 6ca368a3..03a80225 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java
@@ -38,7 +38,7 @@ public interface LockerService {
Map> getLockerMapBySectionId();
/**
- * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다.
+ * 해당 운영 회차에서 회원이 쓰고 있는 사물함. 취소되지 않은 신청은 회차당 한 건까지라 최대 하나다.
*
* 회차 게시 여부와 노출 범위는 확인하지 않는다. 호출하는 쪽이 판단한다.
*/
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java
index 6247affd..1eced3d8 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/AdminLockerServiceImpl.java
@@ -1,13 +1,28 @@
package kr.ac.kookmin.stream.event.domain.locker.service.impl;
+import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.OutboxWriter;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.common.event.LockerAssignedByAdmin;
+import kr.ac.kookmin.stream.common.event.LockerAssignmentCanceled;
+import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
@@ -21,6 +36,7 @@ class AdminLockerServiceImpl implements AdminLockerService {
private final LockerRepository lockerRepository;
private final LockerApplicationRepository lockerApplicationRepository;
+ private final OutboxWriter outboxWriter;
@Override
public List getAllPeriods() {
@@ -66,6 +82,120 @@ public void deletePeriod(Long lockerPeriodId) {
}
}
+ @Override
+ @Transactional
+ public void changeLockerStatus(Long sectionId, List lockerIds, LockerStatus status) {
+ List lockers = getLockersInSection(sectionId, lockerIds);
+ if (!status.isUsable()) {
+ validateNotInUse(lockerIds);
+ }
+
+ lockers.forEach(locker -> locker.changeStatus(status));
+ lockerRepository.saveLockers(lockers);
+ }
+
+ @Override
+ public PageResult getApplications(
+ Long lockerPeriodId, List memberIds, PageOffset pageOffset
+ ) {
+ LockerPeriod period = getPeriod(lockerPeriodId);
+ PageResult applications =
+ lockerApplicationRepository.search(lockerPeriodId, memberIds, pageOffset);
+
+ List lockerIds = applications.content().stream()
+ .map(LockerApplication::getLockerId)
+ .distinct()
+ .toList();
+ // 철거된 사물함도 지난 신청의 이름을 보여줘야 하므로 삭제된 사물함까지 읽는다
+ Map lockers = lockerIds.isEmpty()
+ ? Map.of()
+ : lockerRepository.findLockersByIdsIncludingDeleted(lockerIds).stream()
+ .collect(Collectors.toMap(Locker::getId, Function.identity()));
+
+ return applications.map(
+ application -> new LockerApplicationResult(application, lockers.get(application.getLockerId()), period));
+ }
+
+ @Override
+ @Transactional
+ public void cancelApplication(Long lockerApplicationId) {
+ LockerApplication application = lockerApplicationRepository.findById(lockerApplicationId)
+ .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_APPLICATION_NOT_FOUND));
+ validateUsageNotEnded(application.getLockerPeriodId(), LocalDate.now());
+
+ application.cancel();
+ lockerApplicationRepository.save(application);
+ outboxWriter.record(new LockerAssignmentCanceled(application.getMemberId(), application.getLockerPeriodId()));
+ }
+
+ @Override
+ @Transactional
+ public void assignLocker(Long lockerPeriodId, Long memberId, Long lockerId) {
+ // 사용 기간 판정과 신청 시각이 같은 시계를 보게 한 번만 읽는다. 따로 읽으면 자정을 걸칠 때 기간 안으로
+ // 통과한 신청이 다음 날짜로 기록된다
+ LocalDateTime now = LocalDateTime.now();
+ validateUsageNotEnded(lockerPeriodId, now.toLocalDate());
+ Locker locker = lockerRepository.findLockerById(lockerId)
+ .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION));
+ if (!locker.isSelectable(lockerApplicationRepository.existsByLocker(lockerPeriodId, lockerId))) {
+ throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED);
+ }
+
+ // 회원이 이미 가진 칸이 있으면 저장 시 유니크 제약이 막는다
+ lockerApplicationRepository.save(LockerApplication.create(lockerPeriodId, memberId, lockerId, now));
+ outboxWriter.record(new LockerAssignedByAdmin(memberId, lockerPeriodId));
+ }
+
+ /**
+ * 사용 기간이 끝난 회차는 신청을 손댈 수 없다. 취소해도 되돌릴 자리가 없고, 배정해도 쓸 수 없다.
+ */
+ private void validateUsageNotEnded(Long lockerPeriodId, LocalDate today) {
+ if (getPeriod(lockerPeriodId).isUsageEnded(today)) {
+ throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_ALREADY_ENDED);
+ }
+ }
+
+ /**
+ * 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다.
+ */
+ private List getLockersInSection(Long sectionId, List lockerIds) {
+ // 구역부터 확인한다. 없는 구역이면 같은 경로의 구역 상세 조회와 같이 404로 나가야 한다
+ if (lockerRepository.findSectionById(sectionId).isEmpty()) {
+ throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND);
+ }
+
+ // 구역 전체가 아니라 요청한 사물함만 읽는다. 36칸 구역에서 두 칸만 바꿔도 전체를 읽지 않도록 한다
+ List lockers = lockerRepository.findLockersByIds(lockerIds);
+ boolean allInSection = lockers.size() == lockerIds.size()
+ && lockers.stream().allMatch(locker -> locker.getSectionId().equals(sectionId));
+ if (!allInSection) {
+ // 없는 사물함과 다른 구역 사물함을 가르지 않는다. 운영 화면에서는 둘 다 "잘못 고른 칸"이다
+ throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION);
+ }
+ return lockers;
+ }
+
+ /**
+ * 아직 배정 상태인 신청이 걸린 사물함이 하나라도 있으면 끊는다.
+ *
+ * 배정인지 만료인지는 내 신청 내역 화면과 같은 기준을 써야 하므로 {@link LockerApplicationStatus}에 맡긴다.
+ * 저장소는 판정에 끼지 않고 회차를 읽고 신청 존재만 알려준다.
+ */
+ private void validateNotInUse(List lockerIds) {
+ LocalDate today = LocalDate.now();
+ // 회차를 먼저 좁힌다. 회차는 학기당 한두 건이라 전부 읽어도 되고, 그래야 신청 조회가 (회차, 사물함)
+ // 유니크 인덱스를 탄다. 사물함 식별자만으로 신청을 찾으면 탈 인덱스가 없어 이력 전체를 훑는다.
+ // 게시 여부로 거르지 않는다. 신청은 게시된 회차에만 들어와 지금은 차이가 없고, 거르려면 포트만 하나 더 는다
+ Set assignedPeriodIds = lockerRepository.findAllPeriods().stream()
+ .filter(period -> LockerApplicationStatus.from(period, today) == LockerApplicationStatus.ASSIGNED)
+ .map(LockerPeriod::getId)
+ .collect(Collectors.toSet());
+
+ if (lockerApplicationRepository.existsByPeriodIdsAndLockerIds(assignedPeriodIds, lockerIds)) {
+ throw new BusinessException(LockerErrorCode.LOCKER_IN_USE);
+ }
+ }
+
private LockerPeriod getPeriod(Long lockerPeriodId) {
return lockerRepository.findPeriodById(lockerPeriodId)
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND));
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java
index f766c670..59821916 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java
@@ -32,19 +32,21 @@ class LockerApplicationServiceImpl implements LockerApplicationService {
@Override
@Transactional
public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) {
+ LocalDateTime now = LocalDateTime.now();
LockerPeriod period = getPublishedPeriod(command.lockerPeriodId());
+ validateApplyOpen(period, now);
Locker locker = getSelectableLocker(period.getId(), command.lockerId());
LockerApplication application = lockerApplicationRepository.save(
- LockerApplication.create(period.getId(), memberId, locker.getId(), LocalDateTime.now())
+ LockerApplication.create(period.getId(), memberId, locker.getId(), now)
);
outboxWriter.record(new LockerAssigned(memberId, period.getId()));
return new LockerApplicationResult(application, locker, period);
}
/**
- * 트랜잭션을 걸지 않는다. 신청은 취소·변경이 없고, 조회 사이에 회차 게시·이름이 바뀌어도 각 건은 그 시점에 맞는
- * 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다.
+ * 트랜잭션을 걸지 않는다. 조회 사이에 회차 게시·이름이 바뀌거나 운영진이 신청을 취소해도 각 건은 그 시점에
+ * 맞는 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다.
*/
@Override
public List getApplicationsByMemberId(Long memberId) {
@@ -96,6 +98,19 @@ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) {
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND));
}
+ /**
+ * 모집 기간 밖에는 신청을 받지 않는다. 게시 여부만 보면 운영진이 게시를 내리기 전까지 마감 뒤에도 신청이 들어오고,
+ * 사물함은 선착순 즉시 배정이라 그 신청이 남의 자리를 차지한다. 행사 신청({@code EventApplicationServiceImpl})도
+ * 같은 자리에서 모집 상태를 본다.
+ *
+ * @param now 신청 시각과 같은 값을 쓴다. 따로 잡으면 마감 판정을 통과한 신청이 마감 뒤 시각으로 기록될 수 있다
+ */
+ private void validateApplyOpen(LockerPeriod period, LocalDateTime now) {
+ if (!period.isApplyOpen(now)) {
+ throw new BusinessException(LockerErrorCode.LOCKER_APPLY_NOT_OPEN);
+ }
+ }
+
/**
* 해당 운영 회차에 선택할 수 있는 사물함. 구역 조회와 같은 기준({@link Locker#isSelectable})으로 판정한다.
*
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..d47246ab 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,18 @@ private LockerSectionSummary summarize(
int availableCount = (int) lockers.stream()
.filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId())))
.count();
+ // 사용 중지된 사물함도 배정돼 있으면 센다. 상태 변경 API가 이용 중인 칸의 사용 중지를 막아 그 조합은 흔치
+ // 않지만, DB 직접 수정이나 검사와 신청이 겹친 경우로 남을 수 있다
+ int assignedCount = (int) lockers.stream()
+ .filter(locker -> appliedLockerIds.contains(locker.getId()))
+ .count();
return new LockerSectionSummary(
section.getId(),
section.getLabel(),
availableCount,
totalCount,
+ assignedCount,
SectionAvailabilityStatus.from(availableCount, totalCount)
);
}
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 73f254ca..9cbd4921 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java
@@ -16,6 +16,8 @@
import java.util.Optional;
import java.util.Set;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
@@ -41,16 +43,17 @@ class LockerApplicationServiceImplTest {
private static final Long MEMBER_ID = 100L;
private static final Long LOCKER_ID = 11L;
+ /** 오늘 날짜와 무관하게 늘 모집 기간 안이도록 신청·사용 기간을 아주 먼 과거부터 먼 미래로 둔다. */
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),
- LocalDate.of(2026, 9, 1),
- LocalDate.of(2026, 12, 15),
+ 2000,
+ 1,
+ "2000-1학기",
+ LocalDateTime.of(2000, 1, 1, 0, 0),
+ LocalDateTime.of(2999, 12, 31, 23, 59, 59),
+ LocalDate.of(2000, 1, 2),
+ LocalDate.of(2999, 12, 31),
true
);
}
@@ -199,7 +202,7 @@ private LockerPeriod period(Long id, String name, LocalDate usageStart, LocalDat
}
private LockerApplication application(Long id, Long periodId, Long lockerId, LocalDateTime appliedAt) {
- return LockerApplication.of(id, periodId, MEMBER_ID, lockerId, appliedAt);
+ return LockerApplication.of(id, periodId, MEMBER_ID, lockerId, appliedAt, false);
}
private List applicationIds(List results) {
@@ -359,6 +362,16 @@ public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}
+ @Override
+ public void saveLockers(List lockers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findLockersByIds(Collection lockerIds) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public List findLockersByIdsIncludingDeleted(Collection lockerIds) {
batchLookups++;
@@ -427,6 +440,11 @@ private static final class FakeLockerApplicationRepository implements LockerAppl
private final List saved = new ArrayList<>();
private List memberApplications = List.of();
+ @Override
+ public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
@@ -448,10 +466,21 @@ public LockerApplication save(LockerApplication application) {
application.getLockerPeriodId(),
application.getMemberId(),
application.getLockerId(),
- application.getAppliedAt()
+ application.getAppliedAt(),
+ application.isCanceled()
);
}
+ @Override
+ public Optional findById(Long lockerApplicationId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public List findByMemberId(Long memberId) {
return memberApplications.stream().filter(value -> value.getMemberId().equals(memberId)).toList();
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java
index e6b8ae9f..de252ec1 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java
@@ -12,6 +12,8 @@
import java.util.Set;
import java.util.stream.Stream;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
@@ -323,6 +325,16 @@ public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}
+ @Override
+ public void saveLockers(List lockers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findLockersByIds(Collection lockerIds) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public List findLockersByIdsIncludingDeleted(Collection lockerIds) {
throw new UnsupportedOperationException();
@@ -351,6 +363,11 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) {
// 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다
+ @Override
+ public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
@@ -366,6 +383,16 @@ public LockerApplication save(LockerApplication application) {
throw new UnsupportedOperationException();
}
+ @Override
+ public Optional findById(Long lockerApplicationId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public List findByMemberId(Long memberId) {
throw new UnsupportedOperationException();
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
index 7c16e36a..e10348c6 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
@@ -24,6 +24,10 @@ public enum NotificationType {
// 값: 없음
LOCKER_ASSIGNED(NotificationCategory.LOCKER, "사물함을 신청했어요.", "배정된 사물함 번호를 확인해 주세요."),
+ LOCKER_ASSIGNED_BY_ADMIN(NotificationCategory.LOCKER,
+ "운영진이 사물함을 배정했어요.", "배정된 사물함 번호를 확인해 주세요."),
+ LOCKER_ASSIGNMENT_CANCELED(NotificationCategory.LOCKER,
+ "사물함 배정이 취소됐어요.", "신청 기간이 남아 있으면 다시 신청할 수 있어요."),
LOCKER_APPLY_CLOSING(NotificationCategory.LOCKER, "사물함 신청이 내일 마감돼요.", "이용하려면 기간 내에 신청해 주세요."),
LOCKER_USAGE_ENDING(NotificationCategory.LOCKER, "사물함 이용 기간이 얼마 남지 않았어요.", "만료 전에 개인 물품을 미리 정리해 주세요."),
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java
index 6a8fea67..c08a6f72 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java
@@ -7,6 +7,8 @@
import kr.ac.kookmin.stream.common.event.EventStartingTomorrow;
import kr.ac.kookmin.stream.common.event.LockerApplyClosingTomorrow;
import kr.ac.kookmin.stream.common.event.LockerAssigned;
+import kr.ac.kookmin.stream.common.event.LockerAssignedByAdmin;
+import kr.ac.kookmin.stream.common.event.LockerAssignmentCanceled;
import kr.ac.kookmin.stream.common.event.LockerUsageEndingSoon;
import kr.ac.kookmin.stream.common.event.NoticeCreated;
import kr.ac.kookmin.stream.common.event.RentalDueSoon;
@@ -45,6 +47,19 @@ public void on(LockerAssigned event) {
NotificationType.LOCKER_ASSIGNED, List.of(), event.lockerPeriodId(), List.of(event.memberId()));
}
+ @EventListener
+ public void on(LockerAssignedByAdmin event) {
+ notificationDispatcher.dispatch(
+ NotificationType.LOCKER_ASSIGNED_BY_ADMIN, List.of(), event.lockerPeriodId(), List.of(event.memberId()));
+ }
+
+ // 본인이 하지 않은 변경이고 화면에서는 신청이 사라지므로, 취소는 알림으로만 알 수 있다
+ @EventListener
+ public void on(LockerAssignmentCanceled event) {
+ notificationDispatcher.dispatch(
+ NotificationType.LOCKER_ASSIGNMENT_CANCELED, List.of(), event.lockerPeriodId(), List.of(event.memberId()));
+ }
+
// 공지는 활성 회원 전체에게 간다
@EventListener
public void on(NoticeCreated event) {
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 378a603c..f63af848 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java
@@ -14,19 +14,15 @@
import lombok.NoArgsConstructor;
@Entity
+// uk_locker_applications_active_member_key·uk_locker_applications_active_locker_key(V26)는 generated column
+// active_member_key·active_locker_key에 걸려 있다. Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다.
@Table(
name = "locker_applications",
indexes = {
- @Index(
- name = "uk_locker_applications_locker_period_id_locker_id",
- columnList = "locker_period_id, locker_id",
- unique = true
- ),
@Index(name = "idx_locker_applications_member_id_applied_at", columnList = "member_id, applied_at"),
@Index(
- name = "uk_locker_applications_locker_period_id_member_id",
- columnList = "locker_period_id, member_id",
- unique = true
+ name = "idx_locker_applications_locker_period_id_locker_id_is_canceled",
+ columnList = "locker_period_id, locker_id, is_canceled"
)
}
)
@@ -50,12 +46,16 @@ public class LockerApplicationJpaEntity extends BaseCreatedTimeEntity {
@Column(name = "applied_at", nullable = false)
private LocalDateTime appliedAt;
+ @Column(name = "is_canceled", nullable = false)
+ private boolean isCanceled;
+
private LockerApplicationJpaEntity(LockerApplication application) {
this.id = application.getId();
this.lockerPeriodId = application.getLockerPeriodId();
this.memberId = application.getMemberId();
this.lockerId = application.getLockerId();
this.appliedAt = application.getAppliedAt();
+ this.isCanceled = application.isCanceled();
}
public static LockerApplicationJpaEntity from(LockerApplication application) {
@@ -63,6 +63,6 @@ public static LockerApplicationJpaEntity from(LockerApplication application) {
}
public LockerApplication toDomain() {
- return LockerApplication.of(id, lockerPeriodId, memberId, lockerId, appliedAt);
+ return LockerApplication.of(id, lockerPeriodId, memberId, lockerId, appliedAt, isCanceled);
}
}
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 e5694261..d27cc295 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java
@@ -1,41 +1,75 @@
package kr.ac.kookmin.stream.db.event;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+/**
+ * 취소된 신청은 자리를 비켜준 신청이라 대부분의 조회에서 뺀다. 남겨 두면 취소한 칸을 다시 배정할 수 없다.
+ * 예외는 {@link #existsByLockerPeriodId}와 운영진 신청 현황 조회다.
+ */
public interface LockerApplicationJpaRepository extends JpaRepository {
+ /**
+ * 취소된 신청도 센다. 취소 행도 회차를 가리키므로, 회차를 지우면 가리킬 대상이 없어진다.
+ */
boolean existsByLockerPeriodId(Long lockerPeriodId);
- boolean existsByLockerPeriodIdAndLockerId(Long lockerPeriodId, Long lockerId);
+ boolean existsByLockerPeriodIdAndLockerIdAndIsCanceledFalse(Long lockerPeriodId, Long lockerId);
- List findAllByMemberIdOrderByAppliedAtDescIdDesc(Long memberId);
+ List findAllByMemberIdAndIsCanceledFalseOrderByAppliedAtDescIdDesc(Long memberId);
/**
* 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다.
*/
@Query("""
SELECT a.lockerId FROM LockerApplicationJpaEntity a
- WHERE a.lockerPeriodId = :lockerPeriodId
+ WHERE a.lockerPeriodId = :lockerPeriodId AND a.isCanceled = false
""")
Set findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId);
@Query("""
SELECT a.memberId FROM LockerApplicationJpaEntity a
- WHERE a.lockerPeriodId = :lockerPeriodId
+ WHERE a.lockerPeriodId = :lockerPeriodId AND a.isCanceled = false
""")
List findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId);
+ boolean existsByLockerPeriodIdInAndLockerIdInAndIsCanceledFalse(
+ Collection lockerPeriodIds, Collection lockerIds);
+
+ /**
+ * 운영진 신청 현황. 취소된 신청도 담아 운영진이 그 칸이 왜 비었는지 알 수 있게 한다.
+ */
+ @Query(value = """
+ SELECT a FROM LockerApplicationJpaEntity a
+ WHERE a.lockerPeriodId = :lockerPeriodId
+ AND (:filterByMember = false OR a.memberId IN :memberIds)
+ ORDER BY a.appliedAt DESC, a.id DESC
+ """,
+ countQuery = """
+ SELECT COUNT(a) FROM LockerApplicationJpaEntity a
+ WHERE a.lockerPeriodId = :lockerPeriodId
+ AND (:filterByMember = false OR a.memberId IN :memberIds)
+ """)
+ Page search(
+ @Param("lockerPeriodId") Long lockerPeriodId,
+ @Param("filterByMember") boolean filterByMember,
+ @Param("memberIds") List memberIds,
+ Pageable pageable
+ );
+
/**
* 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다.
*/
@Query("""
SELECT a.lockerId FROM LockerApplicationJpaEntity a
- WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId
+ WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId AND a.isCanceled = false
""")
Optional findLockerIdByLockerPeriodIdAndMemberId(
@Param("lockerPeriodId") Long lockerPeriodId,
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java
index e91dcfce..42a428d6 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java
@@ -1,22 +1,30 @@
package kr.ac.kookmin.stream.db.event;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class LockerApplicationRepositoryImpl implements LockerApplicationRepository {
- private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_locker_period_id_locker_id";
+ // V26에서 유니크 둘을 generated column(active_locker_key·active_member_key)으로 옮겼다. 취소된 신청이 자리를
+ // 잡지 않도록 한 것이라 제약 이름도 함께 바뀐다
+ private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_active_locker_key";
+ private static final String MEMBER_UNIQUE_CONSTRAINT = "uk_locker_applications_active_member_key";
private final LockerApplicationJpaRepository lockerApplicationJpaRepository;
@@ -27,7 +35,7 @@ public boolean existsByPeriod(Long lockerPeriodId) {
@Override
public boolean existsByLocker(Long lockerPeriodId, Long lockerId) {
- return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId);
+ return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerIdAndIsCanceledFalse(lockerPeriodId, lockerId);
}
@Override
@@ -45,13 +53,44 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) {
return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId);
}
+ @Override
+ public boolean existsByPeriodIdsAndLockerIds(Collection lockerPeriodIds, Collection lockerIds) {
+ if (lockerPeriodIds.isEmpty() || lockerIds.isEmpty()) {
+ return false;
+ }
+ return lockerApplicationJpaRepository.existsByLockerPeriodIdInAndLockerIdInAndIsCanceledFalse(lockerPeriodIds, lockerIds);
+ }
+
@Override
public List findByMemberId(Long memberId) {
- return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream()
+ return lockerApplicationJpaRepository.findAllByMemberIdAndIsCanceledFalseOrderByAppliedAtDescIdDesc(memberId).stream()
.map(LockerApplicationJpaEntity::toDomain)
.toList();
}
+ @Override
+ public Optional findById(Long lockerApplicationId) {
+ return lockerApplicationJpaRepository.findById(lockerApplicationId)
+ .map(LockerApplicationJpaEntity::toDomain);
+ }
+
+ @Override
+ public PageResult search(Long lockerPeriodId, List memberIds, PageOffset pageOffset) {
+ boolean filterByMember = memberIds != null;
+ List ids = filterByMember ? memberIds : List.of();
+
+ Page result = lockerApplicationJpaRepository.search(
+ lockerPeriodId, filterByMember, ids, PageRequest.of(pageOffset.page(), pageOffset.size())
+ );
+
+ return PageResult.of(
+ result.getContent().stream().map(LockerApplicationJpaEntity::toDomain).toList(),
+ result.getNumber(),
+ result.getSize(),
+ result.getTotalElements()
+ );
+ }
+
@Override
public LockerApplication save(LockerApplication application) {
try {
@@ -62,6 +101,10 @@ public LockerApplication save(LockerApplication application) {
if (ConstraintViolationUtil.isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) {
throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED);
}
+ // 회원 쪽 제약도 여기서 잡는다. 안 잡으면 운영진이 이미 칸을 가진 학생에게 배정할 때 500이 나간다
+ if (ConstraintViolationUtil.isViolated(e, MEMBER_UNIQUE_CONSTRAINT)) {
+ throw new BusinessException(LockerErrorCode.MEMBER_ALREADY_HAS_LOCKER);
+ }
throw e;
}
}
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 9c38a8af..e19cd69a 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java
@@ -14,6 +14,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
+import org.hibernate.annotations.DynamicUpdate;
@Entity
@Table(
@@ -23,6 +24,10 @@
columnList = "section_id, is_deleted, row_no, column_no"
)
)
+// 바뀐 컬럼만 UPDATE한다. 상태 변경은 도메인에서 만든 엔티티를 merge하는데, 그 엔티티는 삭제 여부를 들고 있지 않아
+// is_deleted가 늘 false다. 사물함 삭제 기능이 붙은 뒤 수정과 삭제가 겹치면 읽어 둔 값으로 되써서 삭제가 되돌아간다
+// (LockerPeriodJpaEntity와 같은 이유).
+@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LockerJpaEntity extends BaseSoftDeleteEntity {
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java
index bdcb4433..a98b8120 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java
@@ -17,6 +17,8 @@ public interface LockerJpaRepository extends JpaRepository findAllByIdIn(Collection ids);
+ List findAllByIdInAndIsDeletedFalse(Collection ids);
+
/**
* 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로
* 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다.
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java
index 1e86220b..073c7c21 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java
@@ -138,6 +138,23 @@ public Optional findLockerById(Long lockerId) {
.map(LockerJpaEntity::toDomain);
}
+ @Override
+ public void saveLockers(List lockers) {
+ lockerJpaRepository.saveAll(lockers.stream()
+ .map(LockerJpaEntity::from)
+ .toList());
+ }
+
+ @Override
+ public List findLockersByIds(Collection lockerIds) {
+ if (lockerIds.isEmpty()) {
+ return List.of();
+ }
+ return lockerJpaRepository.findAllByIdInAndIsDeletedFalse(lockerIds).stream()
+ .map(LockerJpaEntity::toDomain)
+ .toList();
+ }
+
@Override
public List findLockersByIdsIncludingDeleted(Collection lockerIds) {
return lockerJpaRepository.findAllByIdIn(lockerIds).stream()
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..464bbc9a
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql
@@ -0,0 +1,19 @@
+-- 구역 사진을 여러 장 저장한다. 구역당 한 장 전제로 V15에서 단일 컬럼을 뒀는데, 실제로 두 장인 구역이 있다.
+-- 배열 순서가 노출 순서다.
+--
+-- 저장소의 다른 파일 목록 컬럼(notices.image_ids·attachment_ids, events.image_ids, archives.image_ids)과 같은
+-- JSON NULL로 둔다. JSON으로 바꾸면 "반드시 한 장"은 DB가 못 지킨다 — 빈 배열은 NULL이 아니라서 NOT NULL을
+-- 통과한다. 최소 한 장 검증은 구역 쓰기 API에서 한다.
+--
+-- ⚠️ 한 파일에 컬럼 추가·백필·DROP이 함께 있는데 MySQL은 DDL을 트랜잭션으로 묶지 않는다. 중간에 실패하면 앞 문장이
+-- 이미 적용된 채로 남아 손으로 되돌려야 한다.
+-- 그리고 photo_file_id를 지운 뒤에는 이전 이미지로 롤백할 수 없다 — ddl-auto: validate가 없는 컬럼을 찾아 기동을 막는다.
+-- 적용 전 SELECT COUNT(*) FROM locker_section_layouts 로 행 수를 확인한다. 0건이면 백필이 건드릴 행도 없다.
+ALTER TABLE locker_section_layouts
+ ADD COLUMN photo_file_ids JSON NULL;
+
+UPDATE locker_section_layouts
+SET photo_file_ids = JSON_ARRAY(photo_file_id);
+
+ALTER TABLE locker_section_layouts
+ DROP COLUMN photo_file_id;
diff --git a/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql b/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql
new file mode 100644
index 00000000..f84f9447
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V26__add_is_canceled_to_locker_applications.sql
@@ -0,0 +1,34 @@
+-- 운영진이 신청을 취소할 수 있게 한다. 취소해도 행은 남긴다 — 운영진이 그 칸이 왜 비었는지 알아야 하고,
+-- 지난 신청 이력도 남아야 한다.
+--
+-- 승인 절차 없이 신청이 곧 배정이라 값은 "살아 있음/취소됨" 둘뿐이다. 그래서 status 문자열이 아니라 불리언으로 둔다.
+-- "기간종료"는 저장하지 않고 운영 회차로 계산한다(LockerApplicationStatus).
+--
+-- ⚠️ 유니크를 갈아끼우는 동안(DROP 직후 CREATE 사이) 같은 칸 신청이 두 건 들어오면 둘 다 커밋되고
+-- 그 뒤 인덱스 생성이 실패한다. 신청 기간 밖에 적용한다.
+-- 적용 전 SELECT COUNT(*) FROM locker_applications 로 행 수를 확인한다.
+ALTER TABLE locker_applications
+ ADD COLUMN is_canceled TINYINT(1) NOT NULL DEFAULT 0;
+
+-- 취소된 신청이 재배정을 막지 않도록 살아 있는 신청에만 유니크를 건다.
+-- UNIQUE(col, is_canceled)로는 막을 수 없다 — 취소 행끼리의 중복이 그대로 허용된다. (flyway-migration.md 3-4절)
+ALTER TABLE locker_applications
+ ADD COLUMN active_member_key VARCHAR(64)
+ GENERATED ALWAYS AS (IF(is_canceled = 0, CONCAT(locker_period_id, '-', member_id), NULL)) VIRTUAL,
+ ADD COLUMN active_locker_key VARCHAR(64)
+ GENERATED ALWAYS AS (IF(is_canceled = 0, CONCAT(locker_period_id, '-', locker_id), NULL)) VIRTUAL;
+
+DROP INDEX uk_locker_applications_locker_period_id_member_id ON locker_applications;
+DROP INDEX uk_locker_applications_locker_period_id_locker_id ON locker_applications;
+
+CREATE UNIQUE INDEX uk_locker_applications_active_member_key
+ ON locker_applications (active_member_key);
+CREATE UNIQUE INDEX uk_locker_applications_active_locker_key
+ ON locker_applications (active_locker_key);
+
+-- 유니크를 generated column으로 옮기면서 (locker_period_id, locker_id) 접두 조회가 인덱스를 잃는다.
+-- generated column 유니크는 CONCAT 문자열에 걸려 있어 이 조건으로는 탈 수 없다.
+-- 이용 중 판정과 신청 여부 조회가 이 경로를 쓰고, 둘 다 취소된 신청을 빼야 하므로 is_canceled를 뒤에 단다(3-4절).
+-- (V11이 반대 방향으로 했던 교체를 되돌리는 셈이다)
+CREATE INDEX idx_locker_applications_locker_period_id_locker_id_is_canceled
+ ON locker_applications (locker_period_id, locker_id, is_canceled);