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 d34e637c..88e3ceb8 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 @@ -8,6 +8,7 @@ import kr.ac.kookmin.stream.api.app.event.locker.response.LockerApplyResponse; 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.app.event.locker.response.MyLockerApplicationListResponse; 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; @@ -53,4 +54,10 @@ ApiResponse getSectionLockers( @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_ALREADY_ASSIGNED"}) ApiResponse apply(AppApiUser apiUser, LockerApplyRequest request); + + /** 내 사물함 신청 내역. */ + @Operation(summary = "내 배정 사물함 조회", + description = "게시된 운영 회차에서 배정된 내 사물함을 신청 일시 최신순으로 조회한다. 사용 기간이 끝난 회차는 " + + "EXPIRED로 내려간다. 배정된 사물함이 없으면 data가 null이다.") + ApiResponse getMyApplications(AppApiUser apiUser); } 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 49f15e18..3cd79dae 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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.api.app.event.locker; import jakarta.validation.Valid; +import java.time.LocalDate; import java.util.List; import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; @@ -9,8 +10,10 @@ import kr.ac.kookmin.stream.api.app.event.locker.response.LockerApplyResponse; 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.app.event.locker.response.MyLockerApplicationListResponse; 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.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; @@ -79,4 +82,15 @@ public ApiResponse apply( LockerApplyResponse.from(lockerApplicationService.apply(apiUser.userId(), request.toCommand())) ); } + + @Override + @GetMapping("/applications") + public ApiResponse getMyApplications(AppApiUser apiUser) { + List applications = + lockerApplicationService.getApplicationsByMemberId(apiUser.userId()); + // 배정된 사물함이 없으면 빈 목록 대신 data를 비운다 (명세) + return ApiResponse.success( + applications.isEmpty() ? null : MyLockerApplicationListResponse.of(applications, LocalDate.now()) + ); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/MyLockerApplicationListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/MyLockerApplicationListResponse.java new file mode 100644 index 00000000..b230a80c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/MyLockerApplicationListResponse.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +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.LockerPeriod; + +public record MyLockerApplicationListResponse(List applications) { + + /** + * @param today 배정 상태(배정완료·이용종료)를 판정할 기준일 + */ + public static MyLockerApplicationListResponse of(List applications, LocalDate today) { + return new MyLockerApplicationListResponse(applications.stream() + .map(application -> Item.of(application, today)) + .toList()); + } + + public record Item( + Long lockerApplicationId, + Long lockerPeriodId, + String lockerPeriodName, + LockerApplicationStatus applicationStatus, + LocalDateTime appliedAt, + LocalDate usageStartDate, + LocalDate usageEndDate, + String lockerLabel + ) { + + public static Item of(LockerApplicationResult application, LocalDate today) { + LockerPeriod period = application.period(); + return new Item( + application.application().getId(), + period.getId(), + period.getName(), + LockerApplicationStatus.from(period, today), + application.application().getAppliedAt(), + period.getUsageStartAt(), + period.getUsageEndAt(), + application.locker().getLockerLabel() + ); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java index 9552aa74..bb129c8a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java @@ -1,7 +1,9 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; /** - * 사물함 신청 결과. 생성된 신청과 배정된 사물함, 사용 기간을 알려줄 운영 회차를 함께 돌려준다. + * 사물함 신청 한 건과 배정된 사물함, 사용 기간을 알려줄 운영 회차의 묶음. 신청 직후 결과와 회원별 신청 내역에 함께 쓴다. + *

+ * 필드를 복사하지 않고 도메인 객체를 그대로 담는다. 응답에 필요한 값은 표현 계층이 꺼내 쓴다. */ public record LockerApplicationResult(LockerApplication application, Locker locker, LockerPeriod period) { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatus.java new file mode 100644 index 00000000..45e56a60 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatus.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.time.LocalDate; + +/** + * 내 사물함 신청의 배정 상태. 신청이 곧 배정이라 신청 대기 상태는 없고, 사용 기간이 끝났는지로만 가른다. + */ +public enum LockerApplicationStatus { + + ASSIGNED, + EXPIRED; + + public static LockerApplicationStatus from(LockerPeriod period, LocalDate today) { + return period.isUsageEnded(today) ? EXPIRED : ASSIGNED; + } +} 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 4734aca4..4c2b2304 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 @@ -32,4 +32,11 @@ public static LockerPeriod of( ) { return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published); } + + /** + * 사용 기간이 끝났는지. 사용 종료일 당일까지는 사용 중으로 본다. + */ + public boolean isUsageEnded(LocalDate today) { + return today.isAfter(usageEndAt); + } } 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 84197a5f..2817b5e2 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,5 +1,6 @@ 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.LockerApplication; @@ -24,6 +25,11 @@ public interface LockerApplicationRepository { */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); + /** + * 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다. + */ + List findByMemberId(Long memberId); + /** * 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면 * {@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 4ae40d1d..60299c67 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -23,6 +24,11 @@ public interface LockerRepository { */ Optional findPublishedPeriodById(Long lockerPeriodId); + /** + * 게시된 운영 회차 중 식별자에 해당하는 것. 순서는 보장하지 않는다. + */ + List findPublishedPeriodsByIds(Collection lockerPeriodIds); + /** * 전체 구역을 식별자 오름차순으로 조회한다. */ @@ -42,4 +48,9 @@ public interface LockerRepository { * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다. */ Optional findLockerById(Long lockerId); + + /** + * 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다. + */ + List findLockersByIdsIncludingDeleted(Collection lockerIds); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java index 5ca4e7d9..c99645ee 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java @@ -1,13 +1,19 @@ package kr.ac.kookmin.stream.event.domain.locker.service; +import java.util.List; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; -/** 사물함 신청. 구역·배치 조회는 {@link LockerService}가 맡는다. */ +/** 사물함 신청과 그 이력. 구역·배치 조회는 {@link LockerService}가 맡는다. */ public interface LockerApplicationService { /** * 고른 사물함을 신청하고 즉시 배정한다. 같은 사물함에 동시에 신청하면 먼저 저장된 신청만 성공한다. */ LockerApplicationResult apply(Long memberId, LockerApplyCommand command); + + /** + * 게시된 운영 회차에서 회원이 신청한 사물함 내역을 신청 일시 최신순으로 조회한다. 없으면 빈 목록이다. + */ + List getApplicationsByMemberId(Long memberId); } 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 53a2c045..1f3f3c0c 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 @@ -1,6 +1,10 @@ package kr.ac.kookmin.stream.event.domain.locker.service.impl; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +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.LockerApplication; @@ -34,6 +38,54 @@ public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) return new LockerApplicationResult(application, locker, period); } + /** + * 트랜잭션을 걸지 않는다. 신청은 취소·변경이 없고, 조회 사이에 회차 게시·이름이 바뀌어도 각 건은 그 시점에 맞는 + * 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다. + */ + @Override + public List getApplicationsByMemberId(Long memberId) { + List applications = lockerApplicationRepository.findByMemberId(memberId); + if (applications.isEmpty()) { + return List.of(); + } + + Map publishedPeriods = getPublishedPeriods(applications); + Map assignedLockers = getAssignedLockersIncludingRemoved(applications); + + return applications.stream() + // 게시를 내린 회차의 신청은 학생에게 없는 것으로 보여야 한다 + .filter(application -> publishedPeriods.containsKey(application.getLockerPeriodId())) + .map(application -> new LockerApplicationResult( + application, + assignedLockers.get(application.getLockerId()), + publishedPeriods.get(application.getLockerPeriodId()) + )) + .toList(); + } + + /** 신청들이 속한 운영 회차 중 게시된 것. 식별자로 찾아 쓸 수 있게 묶는다. */ + private Map getPublishedPeriods(List applications) { + List lockerPeriodIds = applications.stream() + .map(LockerApplication::getLockerPeriodId) + .distinct() + .toList(); + return lockerRepository.findPublishedPeriodsByIds(lockerPeriodIds).stream() + .collect(Collectors.toMap(LockerPeriod::getId, Function.identity())); + } + + /** + * 신청들에 배정된 사물함. 이용이 끝난 뒤 철거(삭제)된 사물함이어도 지난 신청의 사물함 이름을 보여줘야 하므로 + * 삭제된 사물함도 포함한다. + */ + private Map getAssignedLockersIncludingRemoved(List applications) { + List lockerIds = applications.stream() + .map(LockerApplication::getLockerId) + .distinct() + .toList(); + return lockerRepository.findLockersByIdsIncludingDeleted(lockerIds).stream() + .collect(Collectors.toMap(Locker::getId, Function.identity())); + } + /** 게시된 운영 회차. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 구역 조회와 같은 기준으로 거른다. */ private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { return lockerRepository.findPublishedPeriodById(lockerPeriodId) diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java new file mode 100644 index 00000000..a102eaf7 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LockerApplicationStatusTest { + + private static final LockerPeriod PERIOD = LockerPeriod.of( + 1L, + "2026-2학기", + LocalDateTime.of(2026, 8, 20, 10, 0), + LocalDateTime.of(2026, 8, 25, 18, 0), + LocalDate.of(2026, 9, 7), + LocalDate.of(2026, 12, 15), + true + ); + + @ParameterizedTest + @DisplayName("사용 종료일 당일까지는 배정 상태이고, 다음 날부터 이용 종료다") + @CsvSource({ + "2026-09-01, ASSIGNED", // 사용 시작 전 + "2026-09-07, ASSIGNED", // 사용 시작일 + "2026-12-15, ASSIGNED", // 사용 종료일 당일 + "2026-12-16, EXPIRED", // 사용 종료 다음 날 + "2027-03-02, EXPIRED" + }) + void byUsageEndDate(LocalDate today, LockerApplicationStatus expected) { + assertEquals(expected, LockerApplicationStatus.from(PERIOD, today)); + } +} 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 ef130354..c33098fa 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 @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -30,8 +31,8 @@ import org.junit.jupiter.api.Test; /** - * 사물함 신청 규칙을 확인한다. 동시 신청을 막는 유니크 제약은 DB가 걸기 때문에, 여기서는 저장소가 그 위반을 - * {@code LOCKER_ALREADY_ASSIGNED}로 알렸을 때 서비스가 그대로 전파하는지만 본다. + * 사물함 신청 규칙과 회원별 신청 내역 조합을 확인한다. 동시 신청을 막는 유니크 제약은 DB가 걸기 때문에, 여기서는 + * 저장소가 그 위반을 {@code LOCKER_ALREADY_ASSIGNED}로 알렸을 때 서비스가 그대로 전파하는지만 본다. */ class LockerApplicationServiceImplTest { @@ -176,8 +177,105 @@ private void assertErrorCode(LockerErrorCode expected, FakeLockerRepository repo } } + @Nested + @DisplayName("회원별 신청 내역") + class GetApplicationsByMemberId { + + // 오늘 날짜와 무관하게 상태가 갈리도록 사용 기간을 아주 먼 과거·미래로 둔다 + private final LockerPeriod pastPeriod = + period(2L, "2000-1학기", LocalDate.of(2000, 3, 2), LocalDate.of(2000, 6, 21), true); + private final LockerPeriod currentPeriod = + period(3L, "2999-2학기", LocalDate.of(2999, 9, 1), LocalDate.of(2999, 12, 15), true); + private final LockerPeriod unpublishedPeriod = + period(4L, "비공개 회차", LocalDate.of(2999, 9, 1), LocalDate.of(2999, 12, 15), false); + + private LockerPeriod period(Long id, String name, LocalDate usageStart, LocalDate usageEnd, boolean published) { + LocalDateTime applyAt = usageStart.atStartOfDay(); + return LockerPeriod.of(id, name, applyAt, applyAt, usageStart, usageEnd, published); + } + + private LockerApplication application(Long id, Long periodId, Long lockerId, LocalDateTime appliedAt) { + return LockerApplication.of(id, periodId, MEMBER_ID, lockerId, appliedAt); + } + + private List applicationIds(List results) { + return results.stream().map(result -> result.application().getId()).toList(); + } + + @Test + @DisplayName("저장소가 준 최신순을 유지하고, 신청마다 배정된 사물함과 운영 회차를 짝지어 준다") + void combinesPeriodAndLocker() { + LocalDateTime currentAppliedAt = LocalDateTime.of(2999, 8, 20, 13, 59); + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriods(pastPeriod, currentPeriod) + .withLockersIncludingDeleted(Locker.of(21L, 1L, "B-25", 25, 1, 1, LockerStatus.AVAILABLE), + Locker.of(22L, 1L, "A-14", 14, 1, 2, LockerStatus.AVAILABLE)) + .withMemberApplications( + application(25L, currentPeriod.getId(), 21L, currentAppliedAt), + application(11L, pastPeriod.getId(), 22L, LocalDateTime.of(2000, 3, 1, 10, 15))); + + List results = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(25L, 11L), applicationIds(results)); + + LockerApplicationResult current = results.getFirst(); + assertEquals(currentAppliedAt, current.application().getAppliedAt()); + assertSame(currentPeriod, current.period()); + assertEquals("B-25", current.locker().getLockerLabel()); + + LockerApplicationResult past = results.get(1); + assertSame(pastPeriod, past.period()); + assertEquals("A-14", past.locker().getLockerLabel()); + } + + @Test + @DisplayName("게시를 내린 회차의 신청은 내역에서 빠진다") + void excludesUnpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriods(currentPeriod, unpublishedPeriod) + .withLockersIncludingDeleted(Locker.of(21L, 1L, "B-25", 25, 1, 1, LockerStatus.AVAILABLE)) + .withMemberApplications( + application(30L, unpublishedPeriod.getId(), 21L, LocalDateTime.of(2999, 8, 21, 9, 0)), + application(25L, currentPeriod.getId(), 21L, LocalDateTime.of(2999, 8, 20, 9, 0))); + + List results = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(25L), applicationIds(results)); + } + + @Test + @DisplayName("신청 뒤 삭제된 사물함이어도 지난 신청의 사물함 이름을 보여준다") + void showsDeletedLockerLabel() { + // findLockerById는 삭제된 사물함을 없는 것으로 보므로 신청 내역은 삭제 포함 조회를 써야 한다 + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriods(pastPeriod) + .withLockersIncludingDeleted(Locker.of(22L, 1L, "A-14", 14, 1, 2, LockerStatus.AVAILABLE)) + .withMemberApplications( + application(11L, pastPeriod.getId(), 22L, LocalDateTime.of(2000, 3, 1, 10, 15))); + + List results = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals("A-14", results.getFirst().locker().getLockerLabel()); + } + + @Test + @DisplayName("신청이 없으면 회차·사물함을 조회하지 않고 빈 목록을 돌려준다") + void emptyWithoutApplications() { + FakeLockerRepository repository = new FakeLockerRepository(); + + List results = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(), results); + assertEquals(0, repository.batchLookups); + } + } + /** - * 신청에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. + * 신청·신청 내역에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. *

* 신청 저장·조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. */ @@ -186,6 +284,9 @@ private static final class FakeLockerRepository implements LockerRepository { private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); private LockerPeriod period; private Locker locker; + private List periods = List.of(); + private List lockersIncludingDeleted = List.of(); + private int batchLookups; FakeLockerRepository withPeriod(LockerPeriod value) { this.period = value; @@ -202,6 +303,23 @@ FakeLockerRepository withAppliedLocker(Long lockerPeriodId, Long lockerId) { return this; } + /** 게시 여부와 무관하게 존재하는 회차. 게시된 것만 조회된다. */ + FakeLockerRepository withPeriods(LockerPeriod... values) { + this.periods = List.of(values); + return this; + } + + FakeLockerRepository withLockersIncludingDeleted(Locker... values) { + this.lockersIncludingDeleted = List.of(values); + return this; + } + + /** 저장소가 신청 일시 최신순으로 돌려준다고 보고 넣은 순서를 그대로 쓴다. */ + FakeLockerRepository withMemberApplications(LockerApplication... values) { + applications.memberApplications = List.of(values); + return this; + } + /** 사전 검사와 저장 사이에 다른 신청이 먼저 커밋되어 유니크 제약에 걸린 상황. */ FakeLockerRepository withLosingSave() { applications.losingSave = true; @@ -218,6 +336,21 @@ public Optional findLockerById(Long lockerId) { return Optional.ofNullable(locker).filter(value -> value.getId().equals(lockerId)); } + @Override + public List findPublishedPeriodsByIds(Collection lockerPeriodIds) { + batchLookups++; + return periods.stream() + .filter(LockerPeriod::isPublished) + .filter(value -> lockerPeriodIds.contains(value.getId())) + .toList(); + } + + @Override + public List findLockersByIdsIncludingDeleted(Collection lockerIds) { + batchLookups++; + return lockersIncludingDeleted.stream().filter(value -> lockerIds.contains(value.getId())).toList(); + } + // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override @@ -253,6 +386,7 @@ private static final class FakeLockerApplicationRepository implements LockerAppl private final Set> appliedPeriodLockerIds = new HashSet<>(); private boolean losingSave; private final List saved = new ArrayList<>(); + private List memberApplications = List.of(); @Override public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { @@ -274,6 +408,11 @@ public LockerApplication save(LockerApplication application) { ); } + @Override + public List findByMemberId(Long memberId) { + return memberApplications.stream().filter(value -> value.getMemberId().equals(memberId)).toList(); + } + // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override 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 159e7ed7..48f8b396 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 @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -276,6 +277,18 @@ public Optional findLockerById(Long lockerId) { public Optional findPublishedPeriodById(Long lockerPeriodId) { throw new UnsupportedOperationException(); } + + // 아래는 회원별 신청 내역 조회용 메서드라 이 테스트에서는 쓰지 않는다 + + @Override + public List findPublishedPeriodsByIds(Collection lockerPeriodIds) { + throw new UnsupportedOperationException(); + } + + @Override + public List findLockersByIdsIncludingDeleted(Collection lockerIds) { + throw new UnsupportedOperationException(); + } } private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { @@ -304,5 +317,10 @@ public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { public LockerApplication save(LockerApplication application) { throw new UnsupportedOperationException(); } + + @Override + public List findByMemberId(Long memberId) { + throw new UnsupportedOperationException(); + } } } 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 7d8eba32..66a51e51 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,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.List; import java.util.Optional; import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,6 +11,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository findAllByMemberIdOrderByAppliedAtDescIdDesc(Long memberId); + /** * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다. */ 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 b7b54015..4c08e488 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,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.List; import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.common.BusinessException; @@ -34,6 +35,13 @@ public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); } + @Override + public List findByMemberId(Long memberId) { + return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream() + .map(LockerApplicationJpaEntity::toDomain) + .toList(); + } + @Override public LockerApplication save(LockerApplication application) { try { 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 6ed2124e..bdcb4433 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,6 +11,12 @@ public interface LockerJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); + /** + * 삭제 여부와 무관하게 조회한다. 상속한 {@code findAllById}는 자체 읽기 전용 트랜잭션을 열어 + * 트랜잭션 없이 호출해도 트랜잭션 관리 문장이 붙으므로 선언 쿼리로 둔다. + */ + List findAllByIdIn(Collection ids); + /** * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로 * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다. 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 index 10b876c5..aa76e596 100644 --- 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 @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,4 +10,6 @@ public interface LockerPeriodJpaRepository extends JpaRepository findByIdAndIsPublishedTrue(Long id); + + List findAllByIdInAndIsPublishedTrue(Collection ids); } 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 1f8c9cc5..9f718272 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Collection; import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -33,6 +34,13 @@ public Optional findPublishedPeriodById(Long lockerPeriodId) { .map(LockerPeriodJpaEntity::toDomain); } + @Override + public List findPublishedPeriodsByIds(Collection lockerPeriodIds) { + return lockerPeriodJpaRepository.findAllByIdInAndIsPublishedTrue(lockerPeriodIds).stream() + .map(LockerPeriodJpaEntity::toDomain) + .toList(); + } + @Override public List findAllSections() { return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream() @@ -59,4 +67,11 @@ public Optional findLockerById(Long lockerId) { return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId) .map(LockerJpaEntity::toDomain); } + + @Override + public List findLockersByIdsIncludingDeleted(Collection lockerIds) { + return lockerJpaRepository.findAllByIdIn(lockerIds).stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } }