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);