diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java new file mode 100644 index 00000000..20811a77 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.api.app.event.locker; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 학생 앱 사물함 API의 문서 명세. 구현은 {@link AppLockerController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 + * 파라미터 바인딩(@{@code ModelAttribute}, @{@code PathVariable} 등)은 구현체에 둔다. + */ +@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회") +public interface AppLockerApi { + + /** 구역별 전체·선택 가능 사물함 수와 표시 상태. */ + @Operation(summary = "사물함 구역 목록 조회", + description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. " + + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse getSections( + AppApiUser apiUser, + @ParameterObject LockerPeriodParams params + ); + + /** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */ + @Operation(summary = "사물함 구역 상세 조회", + description = "구역에 속한 사물함의 배치도 위치와 선택 가능 여부를 조회한다. " + + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) + ApiResponse getSectionLockers( + AppApiUser apiUser, + Long sectionId, + @ParameterObject LockerPeriodParams params + ); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java new file mode 100644 index 00000000..4f662ec7 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -0,0 +1,65 @@ +package kr.ac.kookmin.stream.api.app.event.locker; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Set; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 학생 앱의 사물함 구역·배치 조회 API. + *

+ * 선택 가능 여부와 내 사물함 표시는 조회 결과를 응답 DTO에서 맞춰봐서 만든다. 미게시 회차·없는 구역 + * 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다. + */ +@RestController +@RequestMapping("/v1/app/lockers") +@RequiredArgsConstructor +public class AppLockerController implements AppLockerApi { + + private final LockerService lockerService; + + @Override + @GetMapping("/sections") + public ApiResponse getSections( + AppApiUser apiUser, + @Valid @ModelAttribute LockerPeriodParams params + ) { + Long lockerPeriodId = params.lockerPeriodId(); + List sections = lockerService.getSections(lockerPeriodId); + Long mySectionId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) + .map(Locker::getSectionId) + .orElse(null); + + return ApiResponse.success(LockerSectionListResponse.of(sections, mySectionId)); + } + + @Override + @GetMapping("/sections/{sectionId}") + public ApiResponse getSectionLockers( + AppApiUser apiUser, + @PathVariable Long sectionId, + @Valid @ModelAttribute LockerPeriodParams params + ) { + Long lockerPeriodId = params.lockerPeriodId(); + List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); + Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); + Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) + .map(Locker::getId) + .orElse(null); + + return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId)); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java new file mode 100644 index 00000000..eeb9227f --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.api.app.event.locker.request; + +import jakarta.validation.constraints.NotNull; + +/** + * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 + */ +public record LockerPeriodParams( + + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId +) { +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java new file mode 100644 index 00000000..fb04cf58 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.Objects; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; + +public record LockerResponse( + Long lockerId, + String lockerLabel, + int lockerNumber, + int rowNo, + int columnNo, + boolean isAvailable, + boolean isMine +) { + + /** + * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 + * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} + */ + public static LockerResponse of(Locker locker, boolean applied, Long myLockerId) { + return new LockerResponse( + locker.getId(), + locker.getLockerLabel(), + locker.getLockerNumber(), + locker.getRowNo(), + locker.getColumnNo(), + locker.isSelectable(applied), + Objects.equals(locker.getId(), myLockerId) + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java new file mode 100644 index 00000000..4880c6a2 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.List; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; + +public record LockerSectionDetailResponse(List lockers) { + + /** + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} + */ + public static LockerSectionDetailResponse of( + List lockers, + Set appliedLockerIds, + Long myLockerId + ) { + return new LockerSectionDetailResponse(lockers.stream() + .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId)) + .toList()); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java new file mode 100644 index 00000000..37768926 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.Objects; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; + +public record LockerSectionListItemResponse( + Long sectionId, + String section, + int availableCount, + int totalCount, + SectionAvailabilityStatus availabilityStatus, + boolean hasMine +) { + + /** + * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} + */ + public static LockerSectionListItemResponse of(LockerSectionSummary summary, Long mySectionId) { + return new LockerSectionListItemResponse( + summary.sectionId(), + summary.label(), + summary.availableCount(), + summary.totalCount(), + summary.availabilityStatus(), + Objects.equals(summary.sectionId(), mySectionId) + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java new file mode 100644 index 00000000..33e02a80 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public record LockerSectionListResponse(List sections) { + + /** + * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} + */ + public static LockerSectionListResponse of(List sections, Long mySectionId) { + return new LockerSectionListResponse(sections.stream() + .map(summary -> LockerSectionListItemResponse.of(summary, mySectionId)) + .toList()); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index 83d9bd56..552dc6f8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -11,20 +11,35 @@ public class Locker { private Long id; - private String lockerNumber; - private String section; + private Long sectionId; + /** 화면에 표시할 사물함 이름. "A-37" 형태로 관리자가 직접 입력하며 번호에서 유도하지 않는다. */ + private String lockerLabel; + /** 블록 안에서 이어지는 사물함 순번. A-1 구역이 38번까지면 A-2 구역은 39번부터 시작한다. */ + private int lockerNumber; private int rowNo; private int columnNo; private LockerStatus status; public static Locker of( Long id, - String lockerNumber, - String section, + Long sectionId, + String lockerLabel, + int lockerNumber, int rowNo, int columnNo, LockerStatus status ) { - return new Locker(id, lockerNumber, section, rowNo, columnNo, status); + return new Locker(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status); + } + + /** + * 해당 운영 회차에서 선택할 수 있는지. 사물함 자체 상태와 신청 여부를 함께 본다. + *

+ * 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 같은 기준을 써야 하므로 도메인에 둔다. + * + * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 + */ + public boolean isSelectable(boolean applied) { + return status == LockerStatus.AVAILABLE && !applied; } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java new file mode 100644 index 00000000..c71c375d --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum LockerErrorCode implements ErrorCode { + + LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."), + LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java index 04e16cb1..4734aca4 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java @@ -18,6 +18,8 @@ public class LockerPeriod { private LocalDateTime applyEndAt; private LocalDate usageStartAt; private LocalDate usageEndAt; + /** 운영진이 학생에게 공개했는지. 미게시 회차는 학생에게 없는 것으로 보여야 한다. */ + private boolean published; public static LockerPeriod of( Long id, @@ -25,8 +27,9 @@ public static LockerPeriod of( LocalDateTime applyStartAt, LocalDateTime applyEndAt, LocalDate usageStartAt, - LocalDate usageEndAt + LocalDate usageEndAt, + boolean published ) { - return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt); + return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java new file mode 100644 index 00000000..3e5d22c1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 사물함 구역. 사물함을 묶는 단위이자 구역 목록·구역 상세 조회의 기준이다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class LockerSection { + + private Long id; + private String label; + + public static LockerSection of(Long id, String label) { + return new LockerSection(id, label); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java new file mode 100644 index 00000000..ddc68180 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다. + */ +public record LockerSectionSummary( + Long sectionId, + String label, + int availableCount, + int totalCount, + SectionAvailabilityStatus availabilityStatus +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java new file mode 100644 index 00000000..61651b68 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 구역에 남은 사물함 수로 정해지는 표시 상태. + *

+ * 판정에 구역의 속성이 쓰이지 않고 선택 가능 수와 전체 수만 필요해, 상태 타입이 자기 생성을 소유한다. + * 구역별로 임계값이 달라지면 그때 {@link LockerSection}이 임계값을 갖고 판정을 가져가는 편이 맞다. + */ +public enum SectionAvailabilityStatus { + + PLENTY, + NORMAL, + ALMOST_FULL, + FULL; + + private static final double PLENTY_RATE = 0.5; + private static final double NORMAL_RATE = 0.2; + + /** + * @param availableCount 선택 가능한 사물함 수 + * @param totalCount 구역의 전체 사물함 수(사용 중지된 사물함 포함) + */ + public static SectionAvailabilityStatus from(int availableCount, int totalCount) { + // 사물함이 하나도 없는 구역의 0 나누기도 여기서 함께 걸린다 + if (availableCount <= 0) { + return FULL; + } + + double availabilityRate = (double) availableCount / totalCount; + if (availabilityRate >= PLENTY_RATE) { + return PLENTY; + } + if (availabilityRate >= NORMAL_RATE) { + return NORMAL; + } + return ALMOST_FULL; + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java new file mode 100644 index 00000000..9aa40667 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -0,0 +1,47 @@ +package kr.ac.kookmin.stream.event.domain.locker.repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; + +public interface LockerRepository { + + /** + * 게시된 운영 회차가 있는지. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 한다. + */ + boolean existsPublishedPeriod(Long lockerPeriodId); + + boolean existsSection(Long sectionId); + + /** + * 전체 구역을 식별자 오름차순으로 조회한다. + */ + List findAllSections(); + + /** + * 삭제되지 않은 사물함 전체를 조회한다. 구역별 집계는 서비스가 이 목록을 묶어 센다. + */ + List findAllLockers(); + + /** + * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다. + */ + List findLockersBySectionId(Long sectionId); + + /** + * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다. + */ + Optional findLockerById(Long lockerId); + + /** + * 해당 운영 회차에 이미 신청된 사물함 식별자. + */ + Set findAppliedLockerIds(Long lockerPeriodId); + + /** + * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + */ + Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java new file mode 100644 index 00000000..c6730023 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.event.domain.locker.service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public interface LockerService { + + /** + * 운영 회차의 구역별 전체/선택 가능 사물함 수와 표시 상태를 조회한다. + */ + List getSections(Long lockerPeriodId); + + /** + * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다. + *

+ * 선택 가능 여부는 담지 않는다. 판정은 {@link Locker#isSelectable(boolean)}이 갖고 있고, 신청 여부는 + * {@link #getAppliedLockerIds(Long)}로 함께 읽어 호출하는 쪽에서 맞춰본다. + */ + List getSectionLockers(Long lockerPeriodId, Long sectionId); + + /** + * 해당 운영 회차에 이미 신청된 사물함 식별자. + */ + Set getAppliedLockerIds(Long lockerPeriodId); + + /** + * 삭제되지 않은 사물함 전체를 구역별로 묶어 조회한다. + *

+ * 사물함이 한 건도 없는 구역은 키가 없다. 구역 목록을 만들 때처럼 빈 구역도 빠뜨리면 안 되는 쪽은 + * {@code getOrDefault}로 받아야 한다. + */ + Map> getLockerMapBySectionId(); + + /** + * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + *

+ * 회차 게시 여부와 노출 범위는 확인하지 않는다. 호출하는 쪽이 판단한다. + */ + Optional getLockerByMemberId(Long lockerPeriodId, Long memberId); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java new file mode 100644 index 00000000..1f6d356d --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -0,0 +1,106 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class LockerServiceImpl implements LockerService { + + private final LockerRepository lockerRepository; + + @Override + @Transactional(readOnly = true) + public List getSections(Long lockerPeriodId) { + requirePublishedPeriod(lockerPeriodId); + + Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); + Map> lockersBySection = getLockerMapBySectionId(); + + return lockerRepository.findAllSections().stream() + .map(section -> summarize( + section, + // 사물함이 한 건도 없는 구역은 묶음에 키가 없다. 빈 목록으로 채워 목록에서 빠지지 않게 한다 + lockersBySection.getOrDefault(section.getId(), List.of()), + appliedLockerIds + )) + .toList(); + } + + @Override + public List getSectionLockers(Long lockerPeriodId, Long sectionId) { + requirePublishedPeriod(lockerPeriodId); + requireSection(sectionId); + + return lockerRepository.findLockersBySectionId(sectionId); + } + + @Override + public Set getAppliedLockerIds(Long lockerPeriodId) { + return lockerRepository.findAppliedLockerIds(lockerPeriodId); + } + + @Override + public Map> getLockerMapBySectionId() { + return lockerRepository.findAllLockers().stream() + .collect(Collectors.groupingBy(Locker::getSectionId)); + } + + @Override + public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) { + return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) + .flatMap(lockerRepository::findLockerById); + } + + /** + * 구역 하나의 전체·선택 가능 수를 센다. + * + * @param lockers 구역에 속한 사물함. 사물함이 없는 구역이면 빈 목록이다 + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + */ + private LockerSectionSummary summarize( + LockerSection section, + List lockers, + Set appliedLockerIds + ) { + int totalCount = lockers.size(); + int availableCount = (int) lockers.stream() + .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) + .count(); + + return new LockerSectionSummary( + section.getId(), + section.getLabel(), + availableCount, + totalCount, + SectionAvailabilityStatus.from(availableCount, totalCount) + ); + } + + /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */ + private void requirePublishedPeriod(Long lockerPeriodId) { + if (!lockerRepository.existsPublishedPeriod(lockerPeriodId)) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND); + } + } + + private void requireSection(Long sectionId) { + if (!lockerRepository.existsSection(sectionId)) { + throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java new file mode 100644 index 00000000..d922e3ae --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 선택 가능 판정은 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 함께 쓰는 규칙이라 여기서 덮는다. + */ +class LockerTest { + + private static Locker locker(LockerStatus status) { + return Locker.of(1L, 10L, "A-1", 1, 1, 1, status); + } + + @Test + @DisplayName("사용 가능하고 신청되지 않았으면 선택할 수 있다") + void selectableWhenAvailableAndNotApplied() { + assertTrue(locker(LockerStatus.AVAILABLE).isSelectable(false)); + } + + @Test + @DisplayName("사용 가능해도 이미 신청됐으면 선택할 수 없다") + void notSelectableWhenApplied() { + assertFalse(locker(LockerStatus.AVAILABLE).isSelectable(true)); + } + + @Test + @DisplayName("사용 중지된 사물함은 신청되지 않았어도 선택할 수 없다") + void notSelectableWhenDisabled() { + assertFalse(locker(LockerStatus.DISABLED).isSelectable(false)); + } + + @Test + @DisplayName("사용 중지됐고 신청까지 됐으면 선택할 수 없다") + void notSelectableWhenDisabledAndApplied() { + assertFalse(locker(LockerStatus.DISABLED).isSelectable(true)); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java new file mode 100644 index 00000000..4b2c273b --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class SectionAvailabilityStatusTest { + + @ParameterizedTest + @DisplayName("명세의 예시가 모두 같은 상태로 판정된다") + @CsvSource({ + "24, 34, PLENTY", // 70.6% + "6, 6, PLENTY", // 100% + "24, 60, NORMAL", // 40% + "2, 40, ALMOST_FULL", // 5% + "0, 100, FULL" // 0% + }) + void specExamples(int available, int total, SectionAvailabilityStatus expected) { + assertEquals(expected, SectionAvailabilityStatus.from(available, total)); + } + + @ParameterizedTest + @DisplayName("잔여율 경계값은 더 여유로운 쪽으로 판정된다") + @CsvSource({ + "50, 100, PLENTY", // 정확히 50% + "49, 100, NORMAL", // 50% 바로 아래 + "20, 100, NORMAL", // 정확히 20% + "19, 100, ALMOST_FULL" // 20% 바로 아래 + }) + void boundaries(int available, int total, SectionAvailabilityStatus expected) { + assertEquals(expected, SectionAvailabilityStatus.from(available, total)); + } + + @Test + @DisplayName("선택 가능 수가 0이면 전체 수와 무관하게 마감이다") + void zeroAvailableIsFull() { + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 100)); + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 1)); + } + + @Test + @DisplayName("사물함이 없는 구역은 0으로 나누지 않고 마감으로 본다") + void emptySection() { + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 0)); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java new file mode 100644 index 00000000..d1262c57 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -0,0 +1,273 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계가 어긋나지 않는지 확인한다. + *

+ * 선택 가능 여부와 내 사물함 표시는 표현 계층이 조회 결과를 맞춰봐서 만든다. 판정 규칙 자체는 + * {@code LockerTest}가 덮으므로 여기서는 조회가 무엇을 돌려주는지만 본다. + */ +class LockerServiceImplTest { + + private static final Long PERIOD_ID = 1L; + private static final Long MEMBER_ID = 100L; + + private static LockerSection section(Long id, String label) { + return LockerSection.of(id, label); + } + + private static Locker locker(Long id, Long sectionId, LockerStatus status) { + return Locker.of(id, sectionId, "A-" + id, id.intValue(), 1, id.intValue(), status); + } + + private static Locker usable(Long id, Long sectionId) { + return locker(id, sectionId, LockerStatus.AVAILABLE); + } + + @Nested + @DisplayName("구역 목록") + class GetSections { + + @Test + @DisplayName("구역마다 자기 사물함만 세고, 사용 중지·신청된 것은 선택 가능 수에서 빠진다") + void countsPerSection() { + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1"), section(2L, "A-2")) + .withAllLockers( + usable(11L, 1L), + locker(12L, 1L, LockerStatus.DISABLED), + usable(13L, 1L), + usable(21L, 2L)) + .withAppliedLockerIds(13L); + + List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); + + assertEquals(List.of(1L, 2L), sections.stream().map(LockerSectionSummary::sectionId).toList()); + assertEquals(3, sections.get(0).totalCount()); + assertEquals(1, sections.get(0).availableCount()); // 12는 사용 중지, 13은 신청됨 + assertEquals(1, sections.get(1).totalCount()); + assertEquals(1, sections.get(1).availableCount()); + } + + @Test + @DisplayName("사물함이 한 건도 없는 구역도 0으로 채워 목록에서 빠지지 않는다") + void keepsSectionsWithoutLockers() { + // 구역별로 묶으면 사물함이 없는 2번 구역은 키 자체가 생기지 않는다 + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1"), section(2L, "A-2")) + .withAllLockers(usable(11L, 1L)); + + List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); + + assertEquals(2, sections.size()); + assertEquals(0, sections.get(1).totalCount()); + assertEquals(SectionAvailabilityStatus.FULL, sections.get(1).availabilityStatus()); + } + + @Test + @DisplayName("게시되지 않은 회차는 찾을 수 없다") + void unpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSections(PERIOD_ID)); + + assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); + } + } + + @Nested + @DisplayName("구역 상세") + class GetSectionLockers { + + @Test + @DisplayName("구역의 사물함을 레포지토리가 준 순서대로 돌려준다") + void returnsSectionLockers() { + // 선택 가능 판정은 담지 않는다. 판정 자체는 LockerTest가 덮는다 + FakeLockerRepository repository = new FakeLockerRepository() + .withSectionLockers( + usable(11L, 1L), + locker(12L, 1L, LockerStatus.DISABLED), + usable(13L, 1L)); + + List lockers = new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L); + + assertEquals(List.of(11L, 12L, 13L), lockers.stream().map(Locker::getId).toList()); + } + + @Test + @DisplayName("게시되지 않은 회차는 찾을 수 없다") + void unpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L)); + + assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); + } + + @Test + @DisplayName("없는 구역은 찾을 수 없다") + void unknownSection() { + FakeLockerRepository repository = new FakeLockerRepository().withMissingSection(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L)); + + assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode()); + } + } + + @Nested + @DisplayName("회원이 신청한 사물함") + class GetLockerByMemberId { + + @Test + @DisplayName("신청한 사물함을 구역까지 담아 돌려준다") + void returnsAppliedLocker() { + Locker mine = usable(21L, 2L); + FakeLockerRepository repository = new FakeLockerRepository() + .withAllLockers(usable(11L, 1L), mine) + .withMyLocker(mine); + + Optional found = new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID); + + assertTrue(found.isPresent()); + assertEquals(21L, found.get().getId()); + assertEquals(2L, found.get().getSectionId()); + } + + @Test + @DisplayName("신청하지 않았으면 비어 있다") + void emptyWhenNotApplied() { + FakeLockerRepository repository = new FakeLockerRepository() + .withAllLockers(usable(11L, 1L)); + + assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); + } + + @Test + @DisplayName("신청한 사물함이 삭제됐으면 비어 있다") + void emptyWhenLockerRemoved() { + // 신청 행은 남아 있는데 사물함이 삭제된 경우. 사물함 조회가 삭제된 건을 거르므로 여기서도 없는 것이 된다 + FakeLockerRepository repository = new FakeLockerRepository() + .withAllLockers(usable(11L, 1L)) + .withMyLockerId(99L); + + assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); + } + } + + /** 조회 결과만 답하고 집계는 서비스가 하는지 보기 위한 가짜 레포지토리. */ + private static final class FakeLockerRepository implements LockerRepository { + + private boolean publishedPeriod = true; + private boolean sectionExists = true; + private List sections = List.of(); + private List allLockers = List.of(); + private List sectionLockers = List.of(); + private Set appliedLockerIds = Set.of(); + private Long myLockerId; + + FakeLockerRepository withSections(LockerSection... values) { + this.sections = List.of(values); + return this; + } + + FakeLockerRepository withAllLockers(Locker... values) { + this.allLockers = List.of(values); + return this; + } + + FakeLockerRepository withSectionLockers(Locker... values) { + this.sectionLockers = List.of(values); + return this; + } + + FakeLockerRepository withAppliedLockerIds(Long... values) { + this.appliedLockerIds = Set.of(values); + return this; + } + + FakeLockerRepository withMyLocker(Locker value) { + return withMyLockerId(value.getId()); + } + + /** 사물함 목록에 없는 식별자를 넣으면 신청 행만 남고 사물함은 삭제된 상황이 된다. */ + FakeLockerRepository withMyLockerId(Long value) { + this.myLockerId = value; + return this; + } + + FakeLockerRepository withUnpublishedPeriod() { + this.publishedPeriod = false; + return this; + } + + FakeLockerRepository withMissingSection() { + this.sectionExists = false; + return this; + } + + @Override + public boolean existsPublishedPeriod(Long lockerPeriodId) { + return publishedPeriod; + } + + @Override + public boolean existsSection(Long sectionId) { + return sectionExists; + } + + @Override + public List findAllSections() { + return sections; + } + + @Override + public List findAllLockers() { + return allLockers; + } + + @Override + public List findLockersBySectionId(Long sectionId) { + return sectionLockers; + } + + @Override + public Optional findLockerById(Long lockerId) { + return Stream.concat(allLockers.stream(), sectionLockers.stream()) + .filter(locker -> locker.getId().equals(lockerId)) + .findFirst(); + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + return appliedLockerIds; + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return Optional.ofNullable(myLockerId); + } + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java index 43da1796..fcce791d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java @@ -17,7 +17,12 @@ name = "locker_applications", indexes = { @Index(name = "idx_locker_applications_locker_period_id_locker_id", columnList = "locker_period_id, locker_id"), - @Index(name = "idx_locker_applications_member_id", columnList = "member_id") + @Index(name = "idx_locker_applications_member_id", columnList = "member_id"), + @Index( + name = "uk_locker_applications_locker_period_id_member_id", + columnList = "locker_period_id, member_id", + unique = true + ) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java new file mode 100644 index 00000000..a2838015 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.Optional; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface LockerApplicationJpaRepository extends JpaRepository { + + /** + * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다. + */ + @Query(""" + SELECT a.lockerId FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId + """) + Set findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); + + /** + * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. + */ + @Query(""" + SELECT a.lockerId FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId + """) + Optional findLockerIdByLockerPeriodIdAndMemberId( + @Param("lockerPeriodId") Long lockerPeriodId, + @Param("memberId") Long memberId + ); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java index 31d09d11..9c38a8af 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -15,7 +16,13 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "lockers") +@Table( + name = "lockers", + indexes = @Index( + name = "idx_lockers_section_id_is_deleted_row_no_column_no", + columnList = "section_id, is_deleted, row_no, column_no" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LockerJpaEntity extends BaseSoftDeleteEntity { @@ -24,11 +31,14 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity { @Column(name = "locker_id") private Long id; - @Column(name = "locker_number", nullable = false, length = 20) - private String lockerNumber; + @Column(name = "section_id", nullable = false) + private Long sectionId; - @Column(nullable = false, length = 20) - private String section; + @Column(name = "locker_label", nullable = false, length = 20) + private String lockerLabel; + + @Column(name = "locker_number", nullable = false) + private int lockerNumber; @Column(name = "row_no", nullable = false) private int rowNo; @@ -42,8 +52,9 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity { private LockerJpaEntity(Locker locker) { this.id = locker.getId(); + this.sectionId = locker.getSectionId(); + this.lockerLabel = locker.getLockerLabel(); this.lockerNumber = locker.getLockerNumber(); - this.section = locker.getSection(); this.rowNo = locker.getRowNo(); this.columnNo = locker.getColumnNo(); this.status = locker.getStatus(); @@ -54,6 +65,6 @@ public static LockerJpaEntity from(Locker locker) { } public Locker toDomain() { - return Locker.of(id, lockerNumber, section, rowNo, columnNo, status); + return Locker.of(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java new file mode 100644 index 00000000..6ed2124e --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerJpaRepository extends JpaRepository { + + List findAllByIsDeletedFalse(); + + Optional findByIdAndIsDeletedFalse(Long id); + + /** + * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로 + * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다. + */ + List findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(Long sectionId); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java index 98262c4c..afcac785 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java @@ -38,6 +38,9 @@ public class LockerPeriodJpaEntity extends BaseTimeEntity { @Column(name = "usage_end_at", nullable = false) private LocalDate usageEndAt; + @Column(name = "is_published", nullable = false) + private boolean isPublished; + private LockerPeriodJpaEntity(LockerPeriod period) { this.id = period.getId(); this.name = period.getName(); @@ -45,6 +48,7 @@ private LockerPeriodJpaEntity(LockerPeriod period) { this.applyEndAt = period.getApplyEndAt(); this.usageStartAt = period.getUsageStartAt(); this.usageEndAt = period.getUsageEndAt(); + this.isPublished = period.isPublished(); } public static LockerPeriodJpaEntity from(LockerPeriod period) { @@ -52,6 +56,6 @@ public static LockerPeriodJpaEntity from(LockerPeriod period) { } public LockerPeriod toDomain() { - return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt); + return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, isPublished); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java new file mode 100644 index 00000000..c06ffea6 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.db.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerPeriodJpaRepository extends JpaRepository { + + boolean existsByIdAndIsPublishedTrue(Long id); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java new file mode 100644 index 00000000..38dd8fcf --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -0,0 +1,67 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LockerRepositoryImpl implements LockerRepository { + + private final LockerPeriodJpaRepository lockerPeriodJpaRepository; + private final LockerSectionJpaRepository lockerSectionJpaRepository; + private final LockerJpaRepository lockerJpaRepository; + private final LockerApplicationJpaRepository lockerApplicationJpaRepository; + + @Override + public boolean existsPublishedPeriod(Long lockerPeriodId) { + return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrue(lockerPeriodId); + } + + @Override + public boolean existsSection(Long sectionId) { + return lockerSectionJpaRepository.existsById(sectionId); + } + + @Override + public List findAllSections() { + return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream() + .map(LockerSectionJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllLockers() { + return lockerJpaRepository.findAllByIsDeletedFalse().stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } + + @Override + public List findLockersBySectionId(Long sectionId) { + return lockerJpaRepository.findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(sectionId).stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findLockerById(Long lockerId) { + return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId) + .map(LockerJpaEntity::toDomain); + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java new file mode 100644 index 00000000..0b34cfa4 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.db.event; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "locker_sections") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LockerSectionJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "locker_section_id") + private Long id; + + @Column(nullable = false, length = 20) + private String label; + + private LockerSectionJpaEntity(LockerSection section) { + this.id = section.getId(); + this.label = section.getLabel(); + } + + public static LockerSectionJpaEntity from(LockerSection section) { + return new LockerSectionJpaEntity(section); + } + + public LockerSection toDomain() { + return LockerSection.of(id, label); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java new file mode 100644 index 00000000..284d78cd --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerSectionJpaRepository extends JpaRepository { + + /** + * 표시 순서를 따로 두지 않으므로 식별자로 정렬해 같은 요청이 항상 같은 순서를 돌려주게 한다. + */ + List findAllByOrderByIdAsc(); +} diff --git a/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql new file mode 100644 index 00000000..be03433f --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql @@ -0,0 +1,38 @@ +-- 사물함 구역을 lockers.section 문자열에서 별도 테이블로 승격하고, 운영 회차에 게시 여부를 추가한다. +-- +-- ⚠️ lockers에 운영 데이터가 없다는 전제다. 관리자 사물함 등록 API가 아직 없어 행이 쌓일 경로가 없고, +-- locker_label은 관리자가 직접 입력하는 값이라 기존 행에서 유도할 수 없다. +-- 적용 전 SELECT COUNT(*) FROM lockers 로 확인한다. + +CREATE TABLE locker_sections ( + locker_section_id BIGINT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(20) NOT NULL, -- 화면에 표시할 구역 라벨. "A-1", "A-2", "C" + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX uk_locker_sections_label ON locker_sections (label); + +-- locker_number를 INT로 바꾼다. 사물함 번호는 블록 안에서 이어지는 순번이라(A-1이 38번까지면 A-2는 39번부터) +-- 문자열로 두면 정렬이 사전순("1","10","2")이 되고 번호 범위를 다루기 어렵다. +-- locker_label("A-37")은 번호에서 유도하지 않고 관리자가 직접 입력한다. +ALTER TABLE lockers + ADD COLUMN section_id BIGINT NOT NULL, + ADD COLUMN locker_label VARCHAR(20) NOT NULL, + MODIFY COLUMN locker_number INT NOT NULL, + DROP COLUMN section; + +-- 구역 상세 조회: WHERE section_id = ? AND is_deleted = 0 ORDER BY row_no, column_no +-- 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둬 filesort 없이 배치도 순서대로 읽는다. +CREATE INDEX idx_lockers_section_id_is_deleted_row_no_column_no + ON lockers (section_id, is_deleted, row_no, column_no); + +-- 한 회차에 회원당 사물함 한 개만 신청할 수 있고 수정·취소가 없다. 신청 건수 제한을 DB 제약으로 못박는다. +-- 취소가 없어 상태 컬럼이 없으므로 행 존재 자체가 신청이고, soft delete용 generated column 패턴도 필요 없다. +-- 기존 idx_locker_applications_member_id는 유니크의 좌측 prefix가 아니라(회차가 앞) 회원 단독 조회에 여전히 쓰여 남긴다. +CREATE UNIQUE INDEX uk_locker_applications_locker_period_id_member_id + ON locker_applications (locker_period_id, member_id); + +-- 운영진이 준비 중인 회차가 학생에게 노출되지 않도록 게시 여부를 둔다. +-- events(V5)와 같은 방식이며, 관리자 게시 API가 생기기 전까지는 DB에서 직접 켜야 한다. +ALTER TABLE locker_periods + ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 0;