From d5d33b6873d2d13bf6fc4ee69b90cf313640aece Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:55:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신청·회차·사물함을 각각 읽어 서비스에서 짝짓고, 게시를 내린 회차의 신청은 뺀다. 지난 신청의 사물함 이름을 보여줘야 해서 사물함은 삭제된 것도 읽는다. 신청은 LockerApplicationRepository, 회차·사물함은 LockerRepository에서 읽는다. 신청이 곧 배정이라 신청 대기 상태는 두지 않고, 사용 종료일이 지났는지로 ASSIGNED/EXPIRED를 가른다. 판정은 운영 회차가 소유한다. 세 조회가 같은 시점을 볼 필요가 없어 트랜잭션을 걸지 않는다. 사물함 일괄 조회는 상속한 findAllById가 자체 읽기 전용 트랜잭션을 열기 때문에 선언 쿼리 findAllByIdIn으로 둬서 SELECT만 나가게 한다. --- .../domain/LockerApplicationStatus.java | 16 ++++++++ .../domain/LockerApplicationSummary.java | 40 +++++++++++++++++++ .../domain/locker/domain/LockerPeriod.java | 7 ++++ .../LockerApplicationRepository.java | 6 +++ .../locker/repository/LockerRepository.java | 11 +++++ .../service/LockerApplicationService.java | 9 ++++- .../impl/LockerApplicationServiceImpl.java | 39 ++++++++++++++++++ .../LockerApplicationServiceImplTest.java | 18 +++++++++ .../service/impl/LockerServiceImplTest.java | 18 +++++++++ .../event/LockerApplicationJpaRepository.java | 3 ++ .../LockerApplicationRepositoryImpl.java | 8 ++++ .../stream/db/event/LockerJpaRepository.java | 7 ++++ .../db/event/LockerPeriodJpaRepository.java | 4 ++ .../stream/db/event/LockerRepositoryImpl.java | 15 +++++++ 14 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatus.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java 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/LockerApplicationSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java new file mode 100644 index 00000000..3875f1ae --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 회원의 사물함 신청 내역 한 건. 신청에 어느 회차의 어떤 사물함이었는지와 배정 상태를 덧붙인 읽기 모델이다. + */ +public record LockerApplicationSummary( + Long applicationId, + Long lockerPeriodId, + String lockerPeriodName, + LockerApplicationStatus applicationStatus, + LocalDateTime appliedAt, + LocalDate usageStartAt, + LocalDate usageEndAt, + String lockerLabel +) { + + /** + * @param today 배정 상태를 판정할 기준일 + */ + public static LockerApplicationSummary of( + LockerApplication application, + LockerPeriod period, + Locker locker, + LocalDate today + ) { + return new LockerApplicationSummary( + application.getId(), + period.getId(), + period.getName(), + LockerApplicationStatus.from(period, today), + application.getAppliedAt(), + period.getUsageStartAt(), + period.getUsageEndAt(), + locker.getLockerLabel() + ); + } +} 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..1219c525 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,20 @@ 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.LockerApplicationSummary; 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..03fc8d00 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,10 +1,16 @@ 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.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; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; @@ -34,6 +40,39 @@ 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 periods = lockerRepository.findPublishedPeriodsByIds( + applications.stream().map(LockerApplication::getLockerPeriodId).distinct().toList()) + .stream() + .collect(Collectors.toMap(LockerPeriod::getId, Function.identity())); + Map lockers = lockerRepository.findLockersByIdsIncludingDeleted( + applications.stream().map(LockerApplication::getLockerId).distinct().toList()) + .stream() + .collect(Collectors.toMap(Locker::getId, Function.identity())); + + LocalDate today = LocalDate.now(); + return applications.stream() + // 게시를 내린 회차의 신청은 학생에게 없는 것으로 보여야 한다 + .filter(application -> periods.containsKey(application.getLockerPeriodId())) + .map(application -> LockerApplicationSummary.of( + application, + periods.get(application.getLockerPeriodId()), + lockers.get(application.getLockerId()), + today + )) + .toList(); + } + /** 게시된 운영 회차. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 구역 조회와 같은 기준으로 거른다. */ 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/service/impl/LockerApplicationServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java index ef130354..e53f04e7 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 @@ -11,6 +11,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -244,6 +245,18 @@ public List findAllLockers() { public List findLockersBySectionId(Long sectionId) { 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 { @@ -285,5 +298,10 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { throw new UnsupportedOperationException(); } + + @Override + public List findByMemberId(Long memberId) { + throw new UnsupportedOperationException(); + } } } 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(); + } } From b163a63c1765d96ab7a2261c29b5587e80a61a77 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:13:26 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=B0=B0=EC=A0=95=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/app/lockers/applications 로 게시된 회차에서 배정된 내 사물함과 지난 회차 이력을 신청 일시 최신순으로 조회한다. 배정된 사물함이 없으면 명세대로 빈 목록 대신 data를 null로 내려준다. --- .../api/app/event/locker/AppLockerApi.java | 7 ++++ .../app/event/locker/AppLockerController.java | 11 ++++++ .../MyLockerApplicationListResponse.java | 39 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/MyLockerApplicationListResponse.java 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..b1d1b00b 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 @@ -9,8 +9,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.LockerApplicationSummary; 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 +81,13 @@ 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.from(applications)); + } } 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..569c55f0 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/MyLockerApplicationListResponse.java @@ -0,0 +1,39 @@ +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.LockerApplicationStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationSummary; + +public record MyLockerApplicationListResponse(List applications) { + + public static MyLockerApplicationListResponse from(List applications) { + return new MyLockerApplicationListResponse(applications.stream().map(Item::from).toList()); + } + + public record Item( + Long lockerApplicationId, + Long lockerPeriodId, + String lockerPeriodName, + LockerApplicationStatus applicationStatus, + LocalDateTime appliedAt, + LocalDate usageStartDate, + LocalDate usageEndDate, + String lockerLabel + ) { + + public static Item from(LockerApplicationSummary application) { + return new Item( + application.applicationId(), + application.lockerPeriodId(), + application.lockerPeriodName(), + application.applicationStatus(), + application.appliedAt(), + application.usageStartAt(), + application.usageEndAt(), + application.lockerLabel() + ); + } + } +} From ab01697aaec25cd4adb82a5211316895d34f4ca0 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:56:20 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신청 내역이 저장소의 최신순을 유지하며 회차·사물함 정보를 붙이는지, 게시를 내린 회차의 신청을 빼는지, 삭제된 사물함의 이름도 보여주는지, 신청이 없으면 추가 조회 없이 빈 목록을 돌려주는지 확인한다. 배정 상태는 사용 종료일 당일까지 ASSIGNED, 다음 날부터 EXPIRED인지 경계값으로 확인한다. --- .../domain/LockerApplicationStatusTest.java | 35 ++++ .../LockerApplicationServiceImplTest.java | 165 +++++++++++++++--- 2 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationStatusTest.java 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 e53f04e7..a18b78c7 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,8 +10,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.HashSet; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -19,6 +19,8 @@ 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.LockerApplicationSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; @@ -31,8 +33,8 @@ import org.junit.jupiter.api.Test; /** - * 사물함 신청 규칙을 확인한다. 동시 신청을 막는 유니크 제약은 DB가 걸기 때문에, 여기서는 저장소가 그 위반을 - * {@code LOCKER_ALREADY_ASSIGNED}로 알렸을 때 서비스가 그대로 전파하는지만 본다. + * 사물함 신청 규칙과 회원별 신청 내역 조합을 확인한다. 동시 신청을 막는 유니크 제약은 DB가 걸기 때문에, 여기서는 + * 저장소가 그 위반을 {@code LOCKER_ALREADY_ASSIGNED}로 알렸을 때 서비스가 그대로 전파하는지만 본다. */ class LockerApplicationServiceImplTest { @@ -177,8 +179,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); + } + + @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 summaries = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(25L, 11L), summaries.stream().map(LockerApplicationSummary::applicationId).toList()); + + LockerApplicationSummary current = summaries.getFirst(); + assertEquals(currentPeriod.getId(), current.lockerPeriodId()); + assertEquals("2999-2학기", current.lockerPeriodName()); + assertEquals(LockerApplicationStatus.ASSIGNED, current.applicationStatus()); + assertEquals(currentAppliedAt, current.appliedAt()); + assertEquals(LocalDate.of(2999, 9, 1), current.usageStartAt()); + assertEquals(LocalDate.of(2999, 12, 15), current.usageEndAt()); + assertEquals("B-25", current.lockerLabel()); + + LockerApplicationSummary past = summaries.get(1); + assertEquals(LockerApplicationStatus.EXPIRED, past.applicationStatus()); + assertEquals("A-14", past.lockerLabel()); + } + + @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 summaries = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(25L), summaries.stream().map(LockerApplicationSummary::applicationId).toList()); + } + + @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 summaries = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals("A-14", summaries.getFirst().lockerLabel()); + } + + @Test + @DisplayName("신청이 없으면 회차·사물함을 조회하지 않고 빈 목록을 돌려준다") + void emptyWithoutApplications() { + FakeLockerRepository repository = new FakeLockerRepository(); + + List summaries = + service(repository).getApplicationsByMemberId(MEMBER_ID); + + assertEquals(List.of(), summaries); + assertEquals(0, repository.batchLookups); + } + } + /** - * 신청에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. + * 신청·신청 내역에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. *

* 신청 저장·조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. */ @@ -187,6 +286,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; @@ -203,6 +305,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; @@ -219,6 +338,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 @@ -245,18 +379,6 @@ public List findAllLockers() { public List findLockersBySectionId(Long sectionId) { 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 { @@ -266,6 +388,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) { @@ -287,6 +410,11 @@ public LockerApplication save(LockerApplication application) { ); } + @Override + public List findByMemberId(Long memberId) { + return memberApplications.stream().filter(value -> value.getMemberId().equals(memberId)).toList(); + } + // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override @@ -298,10 +426,5 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { throw new UnsupportedOperationException(); } - - @Override - public List findByMemberId(Long memberId) { - throw new UnsupportedOperationException(); - } } } From 4ba360b038f008f5f2a69550b79af5a762053be6 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:33:52 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=A7=A5=EB=9D=BD?= =?UTF-8?q?=EB=B3=84=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신청 내역 조회 한 메서드가 회차·사물함 일괄 조회와 짝짓기를 모두 하고 있어 길고, 삭제된 사물함을 왜 포함하는지도 드러나지 않았다. 게시된 회차(getPublishedPeriods)와 배정된 사물함(getAssignedLockersIncludingRemoved) 조회를 나누고, 이용이 끝난 뒤 철거된 사물함도 지난 신청의 이름을 보여주려고 포함한다는 이유를 메서드 주석에 남긴다. --- .../impl/LockerApplicationServiceImpl.java | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) 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 03fc8d00..b58043b0 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 @@ -51,28 +51,45 @@ public List getApplicationsByMemberId(Long memberId) { return List.of(); } - Map periods = lockerRepository.findPublishedPeriodsByIds( - applications.stream().map(LockerApplication::getLockerPeriodId).distinct().toList()) - .stream() - .collect(Collectors.toMap(LockerPeriod::getId, Function.identity())); - Map lockers = lockerRepository.findLockersByIdsIncludingDeleted( - applications.stream().map(LockerApplication::getLockerId).distinct().toList()) - .stream() - .collect(Collectors.toMap(Locker::getId, Function.identity())); + Map publishedPeriods = getPublishedPeriods(applications); + Map assignedLockers = getAssignedLockersIncludingRemoved(applications); LocalDate today = LocalDate.now(); return applications.stream() // 게시를 내린 회차의 신청은 학생에게 없는 것으로 보여야 한다 - .filter(application -> periods.containsKey(application.getLockerPeriodId())) + .filter(application -> publishedPeriods.containsKey(application.getLockerPeriodId())) .map(application -> LockerApplicationSummary.of( application, - periods.get(application.getLockerPeriodId()), - lockers.get(application.getLockerId()), + publishedPeriods.get(application.getLockerPeriodId()), + assignedLockers.get(application.getLockerId()), today )) .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) From 8f020b8a6f72feb75742667e83a0e4396da1726f Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:36:15 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=97=90=EC=84=9C=20LockerApplicationSummary=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LockerApplicationSummary는 신청·회차·사물함 필드를 응답 DTO(Item)와 1:1로 복사만 하는 객체였다. 식별자를 갖고 스스로 지키는 규칙도 없어 값 객체로 둘 이유가 없고, 필드가 늘 때마다 도메인과 응답 두 곳을 고쳐야 한다. 서비스는 신청 API와 같은 LockerApplicationResult(신청·사물함·회차 도메인 객체 묶음)를 돌려주고, 응답 DTO가 필요한 값을 꺼낸다. 배정 상태는 응답을 만들 때 LockerApplicationStatus.from(period, today)으로 판정한다. --- .../app/event/locker/AppLockerController.java | 9 ++-- .../MyLockerApplicationListResponse.java | 31 ++++++++------ .../domain/LockerApplicationResult.java | 4 +- .../domain/LockerApplicationSummary.java | 40 ------------------ .../service/LockerApplicationService.java | 3 +- .../impl/LockerApplicationServiceImpl.java | 10 ++--- .../LockerApplicationServiceImplTest.java | 42 +++++++++---------- 7 files changed, 52 insertions(+), 87 deletions(-) delete mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java 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 b1d1b00b..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; @@ -12,7 +13,7 @@ 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.LockerApplicationSummary; +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; @@ -85,9 +86,11 @@ public ApiResponse apply( @Override @GetMapping("/applications") public ApiResponse getMyApplications(AppApiUser apiUser) { - List applications = + List applications = lockerApplicationService.getApplicationsByMemberId(apiUser.userId()); // 배정된 사물함이 없으면 빈 목록 대신 data를 비운다 (명세) - return ApiResponse.success(applications.isEmpty() ? null : MyLockerApplicationListResponse.from(applications)); + 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 index 569c55f0..b230a80c 100644 --- 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 @@ -3,13 +3,19 @@ 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.LockerApplicationSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; public record MyLockerApplicationListResponse(List applications) { - public static MyLockerApplicationListResponse from(List applications) { - return new MyLockerApplicationListResponse(applications.stream().map(Item::from).toList()); + /** + * @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( @@ -23,16 +29,17 @@ public record Item( String lockerLabel ) { - public static Item from(LockerApplicationSummary application) { + public static Item of(LockerApplicationResult application, LocalDate today) { + LockerPeriod period = application.period(); return new Item( - application.applicationId(), - application.lockerPeriodId(), - application.lockerPeriodName(), - application.applicationStatus(), - application.appliedAt(), - application.usageStartAt(), - application.usageEndAt(), - application.lockerLabel() + 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/LockerApplicationSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java deleted file mode 100644 index 3875f1ae..00000000 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationSummary.java +++ /dev/null @@ -1,40 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.locker.domain; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -/** - * 회원의 사물함 신청 내역 한 건. 신청에 어느 회차의 어떤 사물함이었는지와 배정 상태를 덧붙인 읽기 모델이다. - */ -public record LockerApplicationSummary( - Long applicationId, - Long lockerPeriodId, - String lockerPeriodName, - LockerApplicationStatus applicationStatus, - LocalDateTime appliedAt, - LocalDate usageStartAt, - LocalDate usageEndAt, - String lockerLabel -) { - - /** - * @param today 배정 상태를 판정할 기준일 - */ - public static LockerApplicationSummary of( - LockerApplication application, - LockerPeriod period, - Locker locker, - LocalDate today - ) { - return new LockerApplicationSummary( - application.getId(), - period.getId(), - period.getName(), - LockerApplicationStatus.from(period, today), - application.getAppliedAt(), - period.getUsageStartAt(), - period.getUsageEndAt(), - locker.getLockerLabel() - ); - } -} 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 1219c525..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 @@ -2,7 +2,6 @@ import java.util.List; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; /** 사물함 신청과 그 이력. 구역·배치 조회는 {@link LockerService}가 맡는다. */ @@ -16,5 +15,5 @@ public interface LockerApplicationService { /** * 게시된 운영 회차에서 회원이 신청한 사물함 내역을 신청 일시 최신순으로 조회한다. 없으면 빈 목록이다. */ - List getApplicationsByMemberId(Long memberId); + 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 b58043b0..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,5 @@ 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; @@ -10,7 +9,6 @@ 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.LockerApplicationSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; @@ -45,7 +43,7 @@ public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) * 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다. */ @Override - public List getApplicationsByMemberId(Long memberId) { + public List getApplicationsByMemberId(Long memberId) { List applications = lockerApplicationRepository.findByMemberId(memberId); if (applications.isEmpty()) { return List.of(); @@ -54,15 +52,13 @@ public List getApplicationsByMemberId(Long memberId) { Map publishedPeriods = getPublishedPeriods(applications); Map assignedLockers = getAssignedLockersIncludingRemoved(applications); - LocalDate today = LocalDate.now(); return applications.stream() // 게시를 내린 회차의 신청은 학생에게 없는 것으로 보여야 한다 .filter(application -> publishedPeriods.containsKey(application.getLockerPeriodId())) - .map(application -> LockerApplicationSummary.of( + .map(application -> new LockerApplicationResult( application, - publishedPeriods.get(application.getLockerPeriodId()), assignedLockers.get(application.getLockerId()), - today + publishedPeriods.get(application.getLockerPeriodId()) )) .toList(); } 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 a18b78c7..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 @@ -19,8 +19,6 @@ 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.LockerApplicationSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; @@ -200,8 +198,12 @@ private LockerApplication application(Long id, Long periodId, Long lockerId, Loc 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("저장소가 준 최신순을 유지하고, 회차·사물함 정보와 사용 기간에 따른 상태를 붙인다") + @DisplayName("저장소가 준 최신순을 유지하고, 신청마다 배정된 사물함과 운영 회차를 짝지어 준다") void combinesPeriodAndLocker() { LocalDateTime currentAppliedAt = LocalDateTime.of(2999, 8, 20, 13, 59); FakeLockerRepository repository = new FakeLockerRepository() @@ -212,23 +214,19 @@ void combinesPeriodAndLocker() { application(25L, currentPeriod.getId(), 21L, currentAppliedAt), application(11L, pastPeriod.getId(), 22L, LocalDateTime.of(2000, 3, 1, 10, 15))); - List summaries = + List results = service(repository).getApplicationsByMemberId(MEMBER_ID); - assertEquals(List.of(25L, 11L), summaries.stream().map(LockerApplicationSummary::applicationId).toList()); + assertEquals(List.of(25L, 11L), applicationIds(results)); - LockerApplicationSummary current = summaries.getFirst(); - assertEquals(currentPeriod.getId(), current.lockerPeriodId()); - assertEquals("2999-2학기", current.lockerPeriodName()); - assertEquals(LockerApplicationStatus.ASSIGNED, current.applicationStatus()); - assertEquals(currentAppliedAt, current.appliedAt()); - assertEquals(LocalDate.of(2999, 9, 1), current.usageStartAt()); - assertEquals(LocalDate.of(2999, 12, 15), current.usageEndAt()); - assertEquals("B-25", current.lockerLabel()); + LockerApplicationResult current = results.getFirst(); + assertEquals(currentAppliedAt, current.application().getAppliedAt()); + assertSame(currentPeriod, current.period()); + assertEquals("B-25", current.locker().getLockerLabel()); - LockerApplicationSummary past = summaries.get(1); - assertEquals(LockerApplicationStatus.EXPIRED, past.applicationStatus()); - assertEquals("A-14", past.lockerLabel()); + LockerApplicationResult past = results.get(1); + assertSame(pastPeriod, past.period()); + assertEquals("A-14", past.locker().getLockerLabel()); } @Test @@ -241,10 +239,10 @@ void excludesUnpublishedPeriod() { 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 summaries = + List results = service(repository).getApplicationsByMemberId(MEMBER_ID); - assertEquals(List.of(25L), summaries.stream().map(LockerApplicationSummary::applicationId).toList()); + assertEquals(List.of(25L), applicationIds(results)); } @Test @@ -257,10 +255,10 @@ void showsDeletedLockerLabel() { .withMemberApplications( application(11L, pastPeriod.getId(), 22L, LocalDateTime.of(2000, 3, 1, 10, 15))); - List summaries = + List results = service(repository).getApplicationsByMemberId(MEMBER_ID); - assertEquals("A-14", summaries.getFirst().lockerLabel()); + assertEquals("A-14", results.getFirst().locker().getLockerLabel()); } @Test @@ -268,10 +266,10 @@ void showsDeletedLockerLabel() { void emptyWithoutApplications() { FakeLockerRepository repository = new FakeLockerRepository(); - List summaries = + List results = service(repository).getApplicationsByMemberId(MEMBER_ID); - assertEquals(List.of(), summaries); + assertEquals(List.of(), results); assertEquals(0, repository.batchLookups); } }