From dcf044e7ec32bb1cef91f8b909aa542ba1569042 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:12:35 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=EC=97=90=20=EC=8B=A0=EC=B2=AD=20=EC=9D=BC?= =?UTF-8?q?=EC=8B=9C=EC=99=80=20=EC=82=AC=EB=AC=BC=ED=95=A8=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 회차에 한 사물함은 한 명에게만 배정되어야 하는데 (회차, 사물함)이 비유니크 인덱스라 동시 신청이 모두 들어갈 수 있었다. 유니크로 바꿔 먼저 커밋된 신청만 남게 한다. 신청 일시는 행사 신청과 같이 앱이 채우는 applied_at 컬럼으로 두고, 회원별 신청 내역 정렬을 위해 회원 인덱스를 (member_id, applied_at)으로 교체한다. --- .../locker/domain/LockerApplication.java | 21 +++++++++++++++++-- .../db/event/LockerApplicationJpaEntity.java | 15 ++++++++++--- ...d_locker_unique_to_locker_applications.sql | 20 ++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V11__add_applied_at_and_locker_unique_to_locker_applications.sql diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java index 6612d944..d5dbaf34 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplication.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -14,8 +15,24 @@ public class LockerApplication { private Long lockerPeriodId; private Long memberId; private Long lockerId; + private LocalDateTime appliedAt; - public static LockerApplication of(Long id, Long lockerPeriodId, Long memberId, Long lockerId) { - return new LockerApplication(id, lockerPeriodId, memberId, lockerId); + public static LockerApplication of( + Long id, + Long lockerPeriodId, + Long memberId, + Long lockerId, + LocalDateTime appliedAt + ) { + return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt); + } + + /** + * 새 신청을 만든다. 식별자는 저장 시 부여된다. 승인 절차 없이 신청이 곧 배정이다. + * + * @param appliedAt 신청 시각. 클라이언트가 보내지 않고 서버 시각으로 기록한다 + */ + public static LockerApplication create(Long lockerPeriodId, Long memberId, Long lockerId, LocalDateTime appliedAt) { + return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt); } } 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 fcce791d..378a603c 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 @@ -7,6 +7,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; +import java.time.LocalDateTime; import kr.ac.kookmin.stream.db.common.BaseCreatedTimeEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; import lombok.AccessLevel; @@ -16,8 +17,12 @@ @Table( 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 = "uk_locker_applications_locker_period_id_locker_id", + columnList = "locker_period_id, locker_id", + unique = true + ), + @Index(name = "idx_locker_applications_member_id_applied_at", columnList = "member_id, applied_at"), @Index( name = "uk_locker_applications_locker_period_id_member_id", columnList = "locker_period_id, member_id", @@ -42,11 +47,15 @@ public class LockerApplicationJpaEntity extends BaseCreatedTimeEntity { @Column(name = "locker_id", nullable = false) private Long lockerId; + @Column(name = "applied_at", nullable = false) + private LocalDateTime appliedAt; + private LockerApplicationJpaEntity(LockerApplication application) { this.id = application.getId(); this.lockerPeriodId = application.getLockerPeriodId(); this.memberId = application.getMemberId(); this.lockerId = application.getLockerId(); + this.appliedAt = application.getAppliedAt(); } public static LockerApplicationJpaEntity from(LockerApplication application) { @@ -54,6 +63,6 @@ public static LockerApplicationJpaEntity from(LockerApplication application) { } public LockerApplication toDomain() { - return LockerApplication.of(id, lockerPeriodId, memberId, lockerId); + return LockerApplication.of(id, lockerPeriodId, memberId, lockerId, appliedAt); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V11__add_applied_at_and_locker_unique_to_locker_applications.sql b/infrastructure/db/src/main/resources/db/migration/V11__add_applied_at_and_locker_unique_to_locker_applications.sql new file mode 100644 index 00000000..796fce3c --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V11__add_applied_at_and_locker_unique_to_locker_applications.sql @@ -0,0 +1,20 @@ +-- 신청 일시를 앱이 정해 저장한다. 행사 신청(event_applications.applied_at)과 같은 방식으로 맞춘다. +-- created_at은 DB 기본값으로만 채워져(insertable = false) 저장 직후 앱이 값을 알 수 없고, 기록용으로 그대로 둔다. +-- 기존 행은 created_at으로 채운다. +ALTER TABLE locker_applications + ADD COLUMN applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP; +UPDATE locker_applications SET applied_at = created_at; + +-- 내 사물함 신청 내역은 WHERE member_id = ? ORDER BY applied_at DESC, locker_application_id DESC 로 조회한다. +-- 기존 idx_locker_applications_member_id는 새 인덱스의 좌측 prefix라 완전히 포함되므로 제거한다. (V7과 같은 패턴) +DROP INDEX idx_locker_applications_member_id ON locker_applications; +CREATE INDEX idx_locker_applications_member_id_applied_at + ON locker_applications (member_id, applied_at); + +-- 같은 회차에 한 사물함은 한 명에게만 배정된다. 동시 신청은 이 제약이 막아 먼저 커밋된 신청만 남는다. +-- 적용 전 중복이 없는지 확인한다: +-- SELECT locker_period_id, locker_id, COUNT(*) FROM locker_applications GROUP BY 1, 2 HAVING COUNT(*) > 1; +-- 유니크 인덱스가 같은 컬럼의 조회를 대신하므로 기존 비유니크 인덱스는 교체한다. +DROP INDEX idx_locker_applications_locker_period_id_locker_id ON locker_applications; +CREATE UNIQUE INDEX uk_locker_applications_locker_period_id_locker_id + ON locker_applications (locker_period_id, locker_id); From b2f1a19211853c21b3c94664589d3ae6245c6b10 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:13:00 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행사와 같이 조회(LockerService)와 신청(LockerApplicationService)을 나눈다. 게시된 회차인지 확인하고, 구역 조회와 같은 기준(Locker.isSelectable)으로 선택할 수 없는 사물함이면 LOCKER_ALREADY_ASSIGNED로 막는다. 이 검사를 지나 동시에 들어온 신청은 (회차, 사물함) 유니크 위반으로 걸리는데, 저장 시 바로 flush해 그 자리에서 제약 이름을 보고 같은 에러 코드로 바꾼다. --- .../domain/LockerApplicationResult.java | 7 +++ .../locker/domain/LockerApplyCommand.java | 7 +++ .../domain/locker/domain/LockerErrorCode.java | 3 +- .../locker/repository/LockerRepository.java | 18 ++++++++ .../service/LockerApplicationService.java | 13 ++++++ .../impl/LockerApplicationServiceImpl.java | 44 +++++++++++++++++++ .../service/impl/LockerServiceImplTest.java | 19 ++++++++ .../event/LockerApplicationJpaRepository.java | 2 + .../db/event/LockerPeriodJpaRepository.java | 3 ++ .../stream/db/event/LockerRepositoryImpl.java | 40 +++++++++++++++++ 10 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplyCommand.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java 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 new file mode 100644 index 00000000..9552aa74 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplicationResult.java @@ -0,0 +1,7 @@ +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/LockerApplyCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplyCommand.java new file mode 100644 index 00000000..99dcf43e --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerApplyCommand.java @@ -0,0 +1,7 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 사물함 신청 요청. 신청할 운영 회차와 고른 사물함을 담는다. + */ +public record LockerApplyCommand(Long lockerPeriodId, Long lockerId) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java index c71c375d..ab9b3e49 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java @@ -12,7 +12,8 @@ public enum LockerErrorCode implements ErrorCode { LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."), - LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."); + LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."), + LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 9aa40667..3eaae15e 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 @@ -4,6 +4,8 @@ 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.LockerApplication; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; public interface LockerRepository { @@ -15,6 +17,11 @@ public interface LockerRepository { boolean existsSection(Long sectionId); + /** + * 게시된 운영 회차 한 건. 아직 공개하지 않은 회차는 없는 것으로 본다. + */ + Optional findPublishedPeriodById(Long lockerPeriodId); + /** * 전체 구역을 식별자 오름차순으로 조회한다. */ @@ -35,6 +42,11 @@ public interface LockerRepository { */ Optional findLockerById(Long lockerId); + /** + * 해당 운영 회차에 이 사물함이 이미 신청되었는지. + */ + boolean existsApplication(Long lockerPeriodId, Long lockerId); + /** * 해당 운영 회차에 이미 신청된 사물함 식별자. */ @@ -44,4 +56,10 @@ public interface LockerRepository { * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); + + /** + * 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면 + * {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다. + */ + LockerApplication saveApplication(LockerApplication application); } 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 new file mode 100644 index 00000000..5ca4e7d9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerApplicationService.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.event.domain.locker.service; + +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; + +/** 사물함 신청. 구역·배치 조회는 {@link LockerService}가 맡는다. */ +public interface LockerApplicationService { + + /** + * 고른 사물함을 신청하고 즉시 배정한다. 같은 사물함에 동시에 신청하면 먼저 저장된 신청만 성공한다. + */ + LockerApplicationResult apply(Long memberId, LockerApplyCommand command); +} 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 new file mode 100644 index 00000000..b12ad262 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import java.time.LocalDateTime; +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.LockerApplyCommand; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class LockerApplicationServiceImpl implements LockerApplicationService { + + private final LockerRepository lockerRepository; + + @Override + @Transactional + public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { + Long lockerPeriodId = command.lockerPeriodId(); + LockerPeriod period = lockerRepository.findPublishedPeriodById(lockerPeriodId) + .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); + + // 구역 상세에서 선택 가능한 사물함만 고를 수 있어 정상 흐름에서는 없는 사물함이 들어오지 않는다 + Locker locker = lockerRepository.findLockerById(command.lockerId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사물함입니다. lockerId=" + command.lockerId())); + + // 흔한 경우를 먼저 거르는 검사일 뿐이다. 이 검사와 저장 사이에 끼어든 신청은 저장 시 유니크 제약이 막는다 + if (!locker.isSelectable(lockerRepository.existsApplication(lockerPeriodId, locker.getId()))) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + + LockerApplication application = lockerRepository.saveApplication( + LockerApplication.create(lockerPeriodId, memberId, locker.getId(), LocalDateTime.now()) + ); + return new LockerApplicationResult(application, locker, period); + } +} 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 d1262c57..31d7f4f8 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 @@ -10,7 +10,9 @@ 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.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; @@ -265,6 +267,23 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { return appliedLockerIds; } + // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + + @Override + public Optional findPublishedPeriodById(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsApplication(Long lockerPeriodId, Long lockerId) { + throw new UnsupportedOperationException(); + } + + @Override + public LockerApplication saveApplication(LockerApplication application) { + throw new UnsupportedOperationException(); + } + @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/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index a2838015..7d8eba32 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 @@ -8,6 +8,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository { + boolean existsByLockerPeriodIdAndLockerId(Long lockerPeriodId, Long lockerId); + /** * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다. */ 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 c06ffea6..10b876c5 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,8 +1,11 @@ package kr.ac.kookmin.stream.db.event; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface LockerPeriodJpaRepository extends JpaRepository { boolean existsByIdAndIsPublishedTrue(Long id); + + Optional findByIdAndIsPublishedTrue(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 index 38dd8fcf..4b41bc95 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 @@ -3,16 +3,24 @@ import java.util.List; import java.util.Optional; import java.util.Set; +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.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; 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.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class LockerRepositoryImpl implements LockerRepository { + private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_locker_period_id_locker_id"; + private final LockerPeriodJpaRepository lockerPeriodJpaRepository; private final LockerSectionJpaRepository lockerSectionJpaRepository; private final LockerJpaRepository lockerJpaRepository; @@ -28,6 +36,12 @@ public boolean existsSection(Long sectionId) { return lockerSectionJpaRepository.existsById(sectionId); } + @Override + public Optional findPublishedPeriodById(Long lockerPeriodId) { + return lockerPeriodJpaRepository.findByIdAndIsPublishedTrue(lockerPeriodId) + .map(LockerPeriodJpaEntity::toDomain); + } + @Override public List findAllSections() { return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream() @@ -55,6 +69,11 @@ public Optional findLockerById(Long lockerId) { .map(LockerJpaEntity::toDomain); } + @Override + public boolean existsApplication(Long lockerPeriodId, Long lockerId) { + return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId); + } + @Override public Set findAppliedLockerIds(Long lockerPeriodId) { return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); @@ -64,4 +83,25 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); } + + @Override + public LockerApplication saveApplication(LockerApplication application) { + try { + // 제약 위반을 이 자리에서 잡으려면 커밋 시점까지 미루지 않고 바로 INSERT를 내보내야 한다 + return lockerApplicationJpaRepository.saveAndFlush(LockerApplicationJpaEntity.from(application)) + .toDomain(); + } catch (DataIntegrityViolationException e) { + if (isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + throw e; + } + } + + /** MySQL은 제약 이름 앞에 테이블명을 붙여 줄 수 있어 포함 여부로 비교한다. */ + private boolean isViolated(DataIntegrityViolationException e, String constraintName) { + return e.getCause() instanceof ConstraintViolationException violation + && violation.getConstraintName() != null + && violation.getConstraintName().contains(constraintName); + } } From e7a603705b932d9671dff2c119de7afb125b4520 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:13:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/app/lockers/applications 로 고른 사물함을 신청하고 즉시 배정한다. 요청에는 구역 조회에 쓴 lockerPeriodId를 함께 받는다. 지난 회차도 게시 상태로 남아 서버가 신청할 회차를 고를 기준이 없고, 조회한 회차와 신청하는 회차를 일치시키기 위해서다. --- .../api/app/event/locker/AppLockerApi.java | 12 +++++++++- .../app/event/locker/AppLockerController.java | 19 ++++++++++++++- .../locker/request/LockerApplyRequest.java | 20 ++++++++++++++++ .../locker/response/LockerApplyResponse.java | 23 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerApplyRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerApplyResponse.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 20811a77..d34e637c 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 @@ -3,7 +3,9 @@ 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.LockerApplyRequest; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; +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.common.dto.ApiResponse; @@ -18,7 +20,7 @@ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 * 파라미터 바인딩(@{@code ModelAttribute}, @{@code PathVariable} 등)은 구현체에 둔다. */ -@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회") +@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회와 신청") public interface AppLockerApi { /** 구역별 전체·선택 가능 사물함 수와 표시 상태. */ @@ -43,4 +45,12 @@ ApiResponse getSectionLockers( Long sectionId, @ParameterObject LockerPeriodParams params ); + + /** 고른 사물함을 신청하고 즉시 배정한다. */ + @Operation(summary = "사물함 신청", + description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 운영 회차당 한 개만 신청할 수 있고 " + + "신청 후에는 취소하거나 변경할 수 없다. 같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.") + @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); } 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 4f662ec7..49f15e18 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 @@ -4,22 +4,27 @@ 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.LockerApplyRequest; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; +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.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.LockerApplicationService; 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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * 학생 앱의 사물함 구역·배치 조회 API. + * 학생 앱의 사물함 구역·배치 조회와 신청 API. *

* 선택 가능 여부와 내 사물함 표시는 조회 결과를 응답 DTO에서 맞춰봐서 만든다. 미게시 회차·없는 구역 * 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다. @@ -30,6 +35,7 @@ public class AppLockerController implements AppLockerApi { private final LockerService lockerService; + private final LockerApplicationService lockerApplicationService; @Override @GetMapping("/sections") @@ -62,4 +68,15 @@ public ApiResponse getSectionLockers( return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId)); } + + @Override + @PostMapping("/applications") + public ApiResponse apply( + AppApiUser apiUser, + @Valid @RequestBody LockerApplyRequest request + ) { + return ApiResponse.success( + LockerApplyResponse.from(lockerApplicationService.apply(apiUser.userId(), request.toCommand())) + ); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerApplyRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerApplyRequest.java new file mode 100644 index 00000000..78eaf1ec --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerApplyRequest.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.api.app.event.locker.request; + +import jakarta.validation.constraints.NotNull; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand; + +/** + * @param lockerPeriodId 신청할 운영 회차. 구역 조회에 쓴 회차 식별자를 그대로 보낸다 + */ +public record LockerApplyRequest( + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId, + + @NotNull(message = "신청할 사물함을 선택해 주세요.") + Long lockerId +) { + + public LockerApplyCommand toCommand() { + return new LockerApplyCommand(lockerPeriodId, lockerId); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerApplyResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerApplyResponse.java new file mode 100644 index 00000000..bacca2ac --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerApplyResponse.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.time.LocalDate; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult; + +public record LockerApplyResponse( + Long lockerApplicationId, + Long lockerId, + String lockerLabel, + LocalDate usageStartDate, + LocalDate usageEndDate +) { + + public static LockerApplyResponse from(LockerApplicationResult result) { + return new LockerApplyResponse( + result.application().getId(), + result.locker().getId(), + result.locker().getLockerLabel(), + result.period().getUsageStartAt(), + result.period().getUsageEndAt() + ); + } +} From c0eacc0bb9ac7285bc430357b16012c0361e771c Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:24:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=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 게시되지 않은 회차, 이미 신청된 사물함, 사용 중지된 사물함, 없는 사물함을 각각 막는지와 신청 성공 시 회차·회원·사물함·신청 시각이 저장되는지 확인한다. 동시 신청의 유니크 위반은 DB가 걸기 때문에, 저장소가 LOCKER_ALREADY_ASSIGNED로 알렸을 때 서비스가 그대로 전파하는지만 본다. --- .../LockerApplicationServiceImplTest.java | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java 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 new file mode 100644 index 00000000..878bbd02 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +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.LockerApplyCommand; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +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; + +/** + * 사물함 신청 규칙을 확인한다. 동시 신청을 막는 유니크 제약은 DB가 걸기 때문에, 여기서는 저장소가 그 위반을 + * {@code LOCKER_ALREADY_ASSIGNED}로 알렸을 때 서비스가 그대로 전파하는지만 본다. + */ +class LockerApplicationServiceImplTest { + + private static final Long PERIOD_ID = 1L; + private static final Long MEMBER_ID = 100L; + private static final Long LOCKER_ID = 11L; + + private static LockerPeriod period() { + return LockerPeriod.of( + PERIOD_ID, + "2026-2학기", + LocalDateTime.of(2026, 8, 20, 10, 0), + LocalDateTime.of(2026, 8, 25, 18, 0), + LocalDate.of(2026, 9, 1), + LocalDate.of(2026, 12, 15), + true + ); + } + + private static Locker locker(LockerStatus status) { + return Locker.of(LOCKER_ID, 1L, "B-25", 25, 1, 1, status); + } + + private static LockerApplyCommand command() { + return new LockerApplyCommand(PERIOD_ID, LOCKER_ID); + } + + @Nested + @DisplayName("신청 성공") + class Success { + + @Test + @DisplayName("요청한 회차·회원·사물함으로 신청 시각을 채워 저장하고, 배정된 사물함과 회차를 돌려준다") + void savesAndReturnsAssignment() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()) + .withLocker(locker(LockerStatus.AVAILABLE)); + + LocalDateTime before = LocalDateTime.now(); + LockerApplicationResult result = new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()); + LocalDateTime after = LocalDateTime.now(); + + LockerApplication saved = repository.saved.getFirst(); + assertNull(saved.getId()); // 식별자는 저장소가 부여한다 + assertEquals(PERIOD_ID, saved.getLockerPeriodId()); + assertEquals(MEMBER_ID, saved.getMemberId()); + assertEquals(LOCKER_ID, saved.getLockerId()); + assertNotNull(saved.getAppliedAt()); + assertFalse(saved.getAppliedAt().isBefore(before)); + assertFalse(saved.getAppliedAt().isAfter(after)); + + assertEquals(FakeLockerRepository.SAVED_ID, result.application().getId()); + assertSame(repository.locker, result.locker()); + assertSame(repository.period, result.period()); + } + + @Test + @DisplayName("다른 회차에 신청된 사물함은 이 회차에서 신청할 수 있다") + void appliedInOtherPeriod() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()) + .withLocker(locker(LockerStatus.AVAILABLE)) + .withAppliedLocker(PERIOD_ID + 1, LOCKER_ID); + + new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()); + + assertEquals(1, repository.saved.size()); + } + } + + @Nested + @DisplayName("신청 거절") + class Rejected { + + @Test + @DisplayName("게시되지 않은(없는) 회차면 LOCKER_PERIOD_NOT_FOUND로 막고 저장하지 않는다") + void unpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository() + .withLocker(locker(LockerStatus.AVAILABLE)); + + assertErrorCode(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, repository); + assertEquals(0, repository.saved.size()); + } + + @Test + @DisplayName("이 회차에 이미 신청된 사물함이면 LOCKER_ALREADY_ASSIGNED로 막고 저장하지 않는다") + void alreadyApplied() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()) + .withLocker(locker(LockerStatus.AVAILABLE)) + .withAppliedLocker(PERIOD_ID, LOCKER_ID); + + assertErrorCode(LockerErrorCode.LOCKER_ALREADY_ASSIGNED, repository); + assertEquals(0, repository.saved.size()); + } + + @Test + @DisplayName("사용 중지된 사물함도 선택할 수 없는 사물함이라 LOCKER_ALREADY_ASSIGNED로 막는다") + void disabledLocker() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()) + .withLocker(locker(LockerStatus.DISABLED)); + + assertErrorCode(LockerErrorCode.LOCKER_ALREADY_ASSIGNED, repository); + assertEquals(0, repository.saved.size()); + } + + @Test + @DisplayName("없는 사물함은 정상 흐름에서 들어올 수 없어 전용 에러 코드 없이 끊는다") + void missingLocker() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()); + + assertThrows( + IllegalArgumentException.class, + () -> new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()) + ); + assertEquals(0, repository.saved.size()); + } + + @Test + @DisplayName("사전 검사를 통과했어도 저장 시 먼저 들어온 신청에 밀리면 LOCKER_ALREADY_ASSIGNED가 그대로 나간다") + void lostConcurrentApplication() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()) + .withLocker(locker(LockerStatus.AVAILABLE)) + .withLosingSave(); + + assertErrorCode(LockerErrorCode.LOCKER_ALREADY_ASSIGNED, repository); + } + + private void assertErrorCode(LockerErrorCode expected, FakeLockerRepository repository) { + BusinessException exception = assertThrows( + BusinessException.class, + () -> new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()) + ); + assertEquals(expected, exception.getErrorCode()); + } + } + + /** 신청에 쓰는 조회·저장만 답하는 가짜 레포지토리. */ + private static final class FakeLockerRepository implements LockerRepository { + + static final Long SAVED_ID = 25L; + + private LockerPeriod period; + private Locker locker; + private final Set> appliedPeriodLockerIds = new HashSet<>(); + private boolean losingSave; + private final List saved = new ArrayList<>(); + + FakeLockerRepository withPeriod(LockerPeriod value) { + this.period = value; + return this; + } + + FakeLockerRepository withLocker(Locker value) { + this.locker = value; + return this; + } + + FakeLockerRepository withAppliedLocker(Long lockerPeriodId, Long lockerId) { + this.appliedPeriodLockerIds.add(List.of(lockerPeriodId, lockerId)); + return this; + } + + /** 사전 검사와 저장 사이에 다른 신청이 먼저 커밋되어 유니크 제약에 걸린 상황. */ + FakeLockerRepository withLosingSave() { + this.losingSave = true; + return this; + } + + @Override + public Optional findPublishedPeriodById(Long lockerPeriodId) { + return Optional.ofNullable(period).filter(value -> value.getId().equals(lockerPeriodId)); + } + + @Override + public Optional findLockerById(Long lockerId) { + return Optional.ofNullable(locker).filter(value -> value.getId().equals(lockerId)); + } + + @Override + public boolean existsApplication(Long lockerPeriodId, Long lockerId) { + return appliedPeriodLockerIds.contains(List.of(lockerPeriodId, lockerId)); + } + + @Override + public LockerApplication saveApplication(LockerApplication application) { + if (losingSave) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + saved.add(application); + return LockerApplication.of( + SAVED_ID, + application.getLockerPeriodId(), + application.getMemberId(), + application.getLockerId(), + application.getAppliedAt() + ); + } + + // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 + + @Override + public boolean existsPublishedPeriod(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsSection(Long sectionId) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAllSections() { + throw new UnsupportedOperationException(); + } + + @Override + public List findAllLockers() { + throw new UnsupportedOperationException(); + } + + @Override + public List findLockersBySectionId(Long sectionId) { + throw new UnsupportedOperationException(); + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + throw new UnsupportedOperationException(); + } + } +} From c2e8c2db9d24b8f3abc74dba05daed8cd8f971a7 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:47:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EA=B2=80=EC=A6=9D=EC=9D=84=20private=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply 안에 회차 조회, 사물함 조회, 선택 가능 판정이 주석과 함께 이어져 있어 무엇을 검증하는지 한눈에 읽히지 않았다. 게시된 회차(getPublishedPeriod)와 선택할 수 있는 사물함(getSelectableLocker)으로 나눠 이름으로 드러낸다. --- .../impl/LockerApplicationServiceImpl.java | 34 +++++++++++++------ 1 file changed, 23 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 b12ad262..467c0f77 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 @@ -23,22 +23,34 @@ class LockerApplicationServiceImpl implements LockerApplicationService { @Override @Transactional public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { - Long lockerPeriodId = command.lockerPeriodId(); - LockerPeriod period = lockerRepository.findPublishedPeriodById(lockerPeriodId) + LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); + Locker locker = getSelectableLocker(period.getId(), command.lockerId()); + + LockerApplication application = lockerRepository.saveApplication( + LockerApplication.create(period.getId(), memberId, locker.getId(), LocalDateTime.now()) + ); + return new LockerApplicationResult(application, locker, period); + } + + /** 게시된 운영 회차. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 구역 조회와 같은 기준으로 거른다. */ + private LockerPeriod getPublishedPeriod(Long lockerPeriodId) { + return lockerRepository.findPublishedPeriodById(lockerPeriodId) .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND)); + } + /** + * 해당 운영 회차에 선택할 수 있는 사물함. 구역 조회와 같은 기준({@link Locker#isSelectable})으로 판정한다. + *

+ * 흔한 경우를 먼저 거르는 검사일 뿐이다. 이 검사와 저장 사이에 끼어든 신청은 저장 시 유니크 제약이 막는다. + */ + private Locker getSelectableLocker(Long lockerPeriodId, Long lockerId) { // 구역 상세에서 선택 가능한 사물함만 고를 수 있어 정상 흐름에서는 없는 사물함이 들어오지 않는다 - Locker locker = lockerRepository.findLockerById(command.lockerId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사물함입니다. lockerId=" + command.lockerId())); + Locker locker = lockerRepository.findLockerById(lockerId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사물함입니다. lockerId=" + lockerId)); - // 흔한 경우를 먼저 거르는 검사일 뿐이다. 이 검사와 저장 사이에 끼어든 신청은 저장 시 유니크 제약이 막는다 - if (!locker.isSelectable(lockerRepository.existsApplication(lockerPeriodId, locker.getId()))) { + if (!locker.isSelectable(lockerRepository.existsApplication(lockerPeriodId, lockerId))) { throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); } - - LockerApplication application = lockerRepository.saveApplication( - LockerApplication.create(lockerPeriodId, memberId, locker.getId(), LocalDateTime.now()) - ); - return new LockerApplicationResult(application, locker, period); + return locker; } } From fb9e2da7da5b883c5b77e496eb82d782f85d3463 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:50:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20LockerApplicationRepository=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LockerRepository가 회차·구역·사물함에 신청 조회·저장까지 맡아 계속 길어지고 있었다. 신청(locker_applications)은 LockerApplicationRepository로 떼어내고, 유니크 위반 변환도 그 구현체(LockerApplicationRepositoryImpl)로 옮긴다. 구역 조회가 쓰던 신청 조회(findAppliedLockerIds, findAppliedLockerId)도 함께 옮겨 신청 관련 접근을 한 곳에 모은다. 테스트의 가짜 레포지토리도 둘로 나눈다. --- .../LockerApplicationRepository.java | 32 +++++++ .../locker/repository/LockerRepository.java | 26 +---- .../impl/LockerApplicationServiceImpl.java | 6 +- .../service/impl/LockerServiceImpl.java | 8 +- .../LockerApplicationServiceImplTest.java | 94 +++++++++++-------- .../service/impl/LockerServiceImplTest.java | 64 ++++++++----- .../LockerApplicationRepositoryImpl.java | 57 +++++++++++ .../stream/db/event/LockerRepositoryImpl.java | 45 --------- 8 files changed, 196 insertions(+), 136 deletions(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java 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 new file mode 100644 index 00000000..84197a5f --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.event.domain.locker.repository; + +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; + +/** + * 사물함 신청 저장소. 운영 회차·구역·사물함은 {@link LockerRepository}가 맡는다. + */ +public interface LockerApplicationRepository { + + /** + * 해당 운영 회차에 이 사물함이 이미 신청되었는지. + */ + boolean existsByLocker(Long lockerPeriodId, Long lockerId); + + /** + * 해당 운영 회차에 이미 신청된 사물함 식별자. + */ + Set findAppliedLockerIds(Long lockerPeriodId); + + /** + * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + */ + Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); + + /** + * 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면 + * {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다. + */ + LockerApplication save(LockerApplication application); +} 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 3eaae15e..4ae40d1d 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 @@ -2,12 +2,13 @@ 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.LockerApplication; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +/** + * 사물함 운영 회차·구역·사물함 저장소. 사물함 신청은 {@link LockerApplicationRepository}가 맡는다. + */ public interface LockerRepository { /** @@ -41,25 +42,4 @@ public interface LockerRepository { * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다. */ Optional findLockerById(Long lockerId); - - /** - * 해당 운영 회차에 이 사물함이 이미 신청되었는지. - */ - boolean existsApplication(Long lockerPeriodId, Long lockerId); - - /** - * 해당 운영 회차에 이미 신청된 사물함 식별자. - */ - Set findAppliedLockerIds(Long lockerPeriodId); - - /** - * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. - */ - Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); - - /** - * 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면 - * {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다. - */ - LockerApplication saveApplication(LockerApplication application); } 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 467c0f77..53a2c045 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 @@ -8,6 +8,7 @@ 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; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService; import lombok.RequiredArgsConstructor; @@ -19,6 +20,7 @@ class LockerApplicationServiceImpl implements LockerApplicationService { private final LockerRepository lockerRepository; + private final LockerApplicationRepository lockerApplicationRepository; @Override @Transactional @@ -26,7 +28,7 @@ public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); Locker locker = getSelectableLocker(period.getId(), command.lockerId()); - LockerApplication application = lockerRepository.saveApplication( + LockerApplication application = lockerApplicationRepository.save( LockerApplication.create(period.getId(), memberId, locker.getId(), LocalDateTime.now()) ); return new LockerApplicationResult(application, locker, period); @@ -48,7 +50,7 @@ private Locker getSelectableLocker(Long lockerPeriodId, Long lockerId) { Locker locker = lockerRepository.findLockerById(lockerId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사물함입니다. lockerId=" + lockerId)); - if (!locker.isSelectable(lockerRepository.existsApplication(lockerPeriodId, lockerId))) { + if (!locker.isSelectable(lockerApplicationRepository.existsByLocker(lockerPeriodId, lockerId))) { throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); } return locker; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index 1f6d356d..e046de99 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -11,6 +11,7 @@ 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.LockerApplicationRepository; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; import lombok.RequiredArgsConstructor; @@ -22,13 +23,14 @@ class LockerServiceImpl implements LockerService { private final LockerRepository lockerRepository; + private final LockerApplicationRepository lockerApplicationRepository; @Override @Transactional(readOnly = true) public List getSections(Long lockerPeriodId) { requirePublishedPeriod(lockerPeriodId); - Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); + Set appliedLockerIds = lockerApplicationRepository.findAppliedLockerIds(lockerPeriodId); Map> lockersBySection = getLockerMapBySectionId(); return lockerRepository.findAllSections().stream() @@ -51,7 +53,7 @@ public List getSectionLockers(Long lockerPeriodId, Long sectionId) { @Override public Set getAppliedLockerIds(Long lockerPeriodId) { - return lockerRepository.findAppliedLockerIds(lockerPeriodId); + return lockerApplicationRepository.findAppliedLockerIds(lockerPeriodId); } @Override @@ -62,7 +64,7 @@ public Map> getLockerMapBySectionId() { @Override public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) { - return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) + return lockerApplicationRepository.findAppliedLockerId(lockerPeriodId, memberId) .flatMap(lockerRepository::findLockerById); } 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 878bbd02..ef130354 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 @@ -23,6 +23,7 @@ import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -54,6 +55,10 @@ private static Locker locker(LockerStatus status) { return Locker.of(LOCKER_ID, 1L, "B-25", 25, 1, 1, status); } + private static LockerApplicationServiceImpl service(FakeLockerRepository repository) { + return new LockerApplicationServiceImpl(repository, repository.applications); + } + private static LockerApplyCommand command() { return new LockerApplyCommand(PERIOD_ID, LOCKER_ID); } @@ -70,10 +75,10 @@ void savesAndReturnsAssignment() { .withLocker(locker(LockerStatus.AVAILABLE)); LocalDateTime before = LocalDateTime.now(); - LockerApplicationResult result = new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()); + LockerApplicationResult result = service(repository).apply(MEMBER_ID, command()); LocalDateTime after = LocalDateTime.now(); - LockerApplication saved = repository.saved.getFirst(); + LockerApplication saved = repository.applications.saved.getFirst(); assertNull(saved.getId()); // 식별자는 저장소가 부여한다 assertEquals(PERIOD_ID, saved.getLockerPeriodId()); assertEquals(MEMBER_ID, saved.getMemberId()); @@ -82,7 +87,7 @@ void savesAndReturnsAssignment() { assertFalse(saved.getAppliedAt().isBefore(before)); assertFalse(saved.getAppliedAt().isAfter(after)); - assertEquals(FakeLockerRepository.SAVED_ID, result.application().getId()); + assertEquals(FakeLockerApplicationRepository.SAVED_ID, result.application().getId()); assertSame(repository.locker, result.locker()); assertSame(repository.period, result.period()); } @@ -95,9 +100,9 @@ void appliedInOtherPeriod() { .withLocker(locker(LockerStatus.AVAILABLE)) .withAppliedLocker(PERIOD_ID + 1, LOCKER_ID); - new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()); + service(repository).apply(MEMBER_ID, command()); - assertEquals(1, repository.saved.size()); + assertEquals(1, repository.applications.saved.size()); } } @@ -112,7 +117,7 @@ void unpublishedPeriod() { .withLocker(locker(LockerStatus.AVAILABLE)); assertErrorCode(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, repository); - assertEquals(0, repository.saved.size()); + assertEquals(0, repository.applications.saved.size()); } @Test @@ -124,7 +129,7 @@ void alreadyApplied() { .withAppliedLocker(PERIOD_ID, LOCKER_ID); assertErrorCode(LockerErrorCode.LOCKER_ALREADY_ASSIGNED, repository); - assertEquals(0, repository.saved.size()); + assertEquals(0, repository.applications.saved.size()); } @Test @@ -135,7 +140,7 @@ void disabledLocker() { .withLocker(locker(LockerStatus.DISABLED)); assertErrorCode(LockerErrorCode.LOCKER_ALREADY_ASSIGNED, repository); - assertEquals(0, repository.saved.size()); + assertEquals(0, repository.applications.saved.size()); } @Test @@ -146,9 +151,9 @@ void missingLocker() { assertThrows( IllegalArgumentException.class, - () -> new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()) + () -> service(repository).apply(MEMBER_ID, command()) ); - assertEquals(0, repository.saved.size()); + assertEquals(0, repository.applications.saved.size()); } @Test @@ -165,22 +170,22 @@ void lostConcurrentApplication() { private void assertErrorCode(LockerErrorCode expected, FakeLockerRepository repository) { BusinessException exception = assertThrows( BusinessException.class, - () -> new LockerApplicationServiceImpl(repository).apply(MEMBER_ID, command()) + () -> service(repository).apply(MEMBER_ID, command()) ); assertEquals(expected, exception.getErrorCode()); } } - /** 신청에 쓰는 조회·저장만 답하는 가짜 레포지토리. */ + /** + * 신청에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. + *

+ * 신청 저장·조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. + */ private static final class FakeLockerRepository implements LockerRepository { - static final Long SAVED_ID = 25L; - + private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); private LockerPeriod period; private Locker locker; - private final Set> appliedPeriodLockerIds = new HashSet<>(); - private boolean losingSave; - private final List saved = new ArrayList<>(); FakeLockerRepository withPeriod(LockerPeriod value) { this.period = value; @@ -193,13 +198,13 @@ FakeLockerRepository withLocker(Locker value) { } FakeLockerRepository withAppliedLocker(Long lockerPeriodId, Long lockerId) { - this.appliedPeriodLockerIds.add(List.of(lockerPeriodId, lockerId)); + applications.appliedPeriodLockerIds.add(List.of(lockerPeriodId, lockerId)); return this; } /** 사전 검사와 저장 사이에 다른 신청이 먼저 커밋되어 유니크 제약에 걸린 상황. */ FakeLockerRepository withLosingSave() { - this.losingSave = true; + applications.losingSave = true; return this; } @@ -213,26 +218,6 @@ public Optional findLockerById(Long lockerId) { return Optional.ofNullable(locker).filter(value -> value.getId().equals(lockerId)); } - @Override - public boolean existsApplication(Long lockerPeriodId, Long lockerId) { - return appliedPeriodLockerIds.contains(List.of(lockerPeriodId, lockerId)); - } - - @Override - public LockerApplication saveApplication(LockerApplication application) { - if (losingSave) { - throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); - } - saved.add(application); - return LockerApplication.of( - SAVED_ID, - application.getLockerPeriodId(), - application.getMemberId(), - application.getLockerId(), - application.getAppliedAt() - ); - } - // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override @@ -259,6 +244,37 @@ public List findAllLockers() { public List findLockersBySectionId(Long sectionId) { throw new UnsupportedOperationException(); } + } + + private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { + + static final Long SAVED_ID = 25L; + + private final Set> appliedPeriodLockerIds = new HashSet<>(); + private boolean losingSave; + private final List saved = new ArrayList<>(); + + @Override + public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { + return appliedPeriodLockerIds.contains(List.of(lockerPeriodId, lockerId)); + } + + @Override + public LockerApplication save(LockerApplication application) { + if (losingSave) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + saved.add(application); + return LockerApplication.of( + SAVED_ID, + application.getLockerPeriodId(), + application.getMemberId(), + application.getLockerId(), + application.getAppliedAt() + ); + } + + // 아래는 구역·배치 조회(LockerService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override public Set findAppliedLockerIds(Long lockerPeriodId) { 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 31d7f4f8..159e7ed7 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 @@ -17,6 +17,7 @@ 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.LockerApplicationRepository; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -41,6 +42,10 @@ 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 LockerServiceImpl service(FakeLockerRepository repository) { + return new LockerServiceImpl(repository, repository.applications); + } + private static Locker usable(Long id, Long sectionId) { return locker(id, sectionId, LockerStatus.AVAILABLE); } @@ -61,7 +66,7 @@ void countsPerSection() { usable(21L, 2L)) .withAppliedLockerIds(13L); - List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); + List sections = service(repository).getSections(PERIOD_ID); assertEquals(List.of(1L, 2L), sections.stream().map(LockerSectionSummary::sectionId).toList()); assertEquals(3, sections.get(0).totalCount()); @@ -78,7 +83,7 @@ void keepsSectionsWithoutLockers() { .withSections(section(1L, "A-1"), section(2L, "A-2")) .withAllLockers(usable(11L, 1L)); - List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); + List sections = service(repository).getSections(PERIOD_ID); assertEquals(2, sections.size()); assertEquals(0, sections.get(1).totalCount()); @@ -91,7 +96,7 @@ void unpublishedPeriod() { FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); BusinessException e = assertThrows(BusinessException.class, - () -> new LockerServiceImpl(repository).getSections(PERIOD_ID)); + () -> service(repository).getSections(PERIOD_ID)); assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); } @@ -111,7 +116,7 @@ void returnsSectionLockers() { locker(12L, 1L, LockerStatus.DISABLED), usable(13L, 1L)); - List lockers = new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L); + List lockers = service(repository).getSectionLockers(PERIOD_ID, 1L); assertEquals(List.of(11L, 12L, 13L), lockers.stream().map(Locker::getId).toList()); } @@ -122,7 +127,7 @@ void unpublishedPeriod() { FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); BusinessException e = assertThrows(BusinessException.class, - () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L)); + () -> service(repository).getSectionLockers(PERIOD_ID, 1L)); assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); } @@ -133,7 +138,7 @@ void unknownSection() { FakeLockerRepository repository = new FakeLockerRepository().withMissingSection(); BusinessException e = assertThrows(BusinessException.class, - () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L)); + () -> service(repository).getSectionLockers(PERIOD_ID, 99L)); assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode()); } @@ -151,7 +156,7 @@ void returnsAppliedLocker() { .withAllLockers(usable(11L, 1L), mine) .withMyLocker(mine); - Optional found = new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID); + Optional found = service(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID); assertTrue(found.isPresent()); assertEquals(21L, found.get().getId()); @@ -164,7 +169,7 @@ void emptyWhenNotApplied() { FakeLockerRepository repository = new FakeLockerRepository() .withAllLockers(usable(11L, 1L)); - assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); + assertTrue(service(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); } @Test @@ -175,20 +180,24 @@ void emptyWhenLockerRemoved() { .withAllLockers(usable(11L, 1L)) .withMyLockerId(99L); - assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); + assertTrue(service(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); } } - /** 조회 결과만 답하고 집계는 서비스가 하는지 보기 위한 가짜 레포지토리. */ + /** + * 조회 결과만 답하고 집계는 서비스가 하는지 보기 위한 가짜 레포지토리. + *

+ * 신청 조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. + */ private static final class FakeLockerRepository implements LockerRepository { + private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); + 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); @@ -206,7 +215,7 @@ FakeLockerRepository withSectionLockers(Locker... values) { } FakeLockerRepository withAppliedLockerIds(Long... values) { - this.appliedLockerIds = Set.of(values); + applications.appliedLockerIds = Set.of(values); return this; } @@ -216,7 +225,7 @@ FakeLockerRepository withMyLocker(Locker value) { /** 사물함 목록에 없는 식별자를 넣으면 신청 행만 남고 사물함은 삭제된 상황이 된다. */ FakeLockerRepository withMyLockerId(Long value) { - this.myLockerId = value; + applications.myLockerId = value; return this; } @@ -262,31 +271,38 @@ public Optional findLockerById(Long lockerId) { .findFirst(); } + // 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 @Override - public Set findAppliedLockerIds(Long lockerPeriodId) { - return appliedLockerIds; + public Optional findPublishedPeriodById(Long lockerPeriodId) { + throw new UnsupportedOperationException(); } + } - // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { + + private Set appliedLockerIds = Set.of(); + private Long myLockerId; @Override - public Optional findPublishedPeriodById(Long lockerPeriodId) { - throw new UnsupportedOperationException(); + public Set findAppliedLockerIds(Long lockerPeriodId) { + return appliedLockerIds; } @Override - public boolean existsApplication(Long lockerPeriodId, Long lockerId) { - throw new UnsupportedOperationException(); + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return Optional.ofNullable(myLockerId); } + // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + @Override - public LockerApplication saveApplication(LockerApplication application) { + public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { throw new UnsupportedOperationException(); } @Override - public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { - return Optional.ofNullable(myLockerId); + public LockerApplication save(LockerApplication application) { + throw new UnsupportedOperationException(); } } } 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 new file mode 100644 index 00000000..b7b54015 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -0,0 +1,57 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; +import lombok.RequiredArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LockerApplicationRepositoryImpl implements LockerApplicationRepository { + + private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_locker_period_id_locker_id"; + + private final LockerApplicationJpaRepository lockerApplicationJpaRepository; + + @Override + public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { + return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId); + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); + } + + @Override + public LockerApplication save(LockerApplication application) { + try { + // 제약 위반을 이 자리에서 잡으려면 커밋 시점까지 미루지 않고 바로 INSERT를 내보내야 한다 + return lockerApplicationJpaRepository.saveAndFlush(LockerApplicationJpaEntity.from(application)) + .toDomain(); + } catch (DataIntegrityViolationException e) { + if (isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { + throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); + } + throw e; + } + } + + /** MySQL은 제약 이름 앞에 테이블명을 붙여 줄 수 있어 포함 여부로 비교한다. */ + private boolean isViolated(DataIntegrityViolationException e, String constraintName) { + return e.getCause() instanceof ConstraintViolationException violation + && violation.getConstraintName() != null + && violation.getConstraintName().contains(constraintName); + } +} 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 4b41bc95..1f8c9cc5 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 @@ -2,29 +2,20 @@ import java.util.List; import java.util.Optional; -import java.util.Set; -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.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; 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.hibernate.exception.ConstraintViolationException; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class LockerRepositoryImpl implements LockerRepository { - private static final String LOCKER_UNIQUE_CONSTRAINT = "uk_locker_applications_locker_period_id_locker_id"; - private final LockerPeriodJpaRepository lockerPeriodJpaRepository; private final LockerSectionJpaRepository lockerSectionJpaRepository; private final LockerJpaRepository lockerJpaRepository; - private final LockerApplicationJpaRepository lockerApplicationJpaRepository; @Override public boolean existsPublishedPeriod(Long lockerPeriodId) { @@ -68,40 +59,4 @@ public Optional findLockerById(Long lockerId) { return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId) .map(LockerJpaEntity::toDomain); } - - @Override - public boolean existsApplication(Long lockerPeriodId, Long lockerId) { - return lockerApplicationJpaRepository.existsByLockerPeriodIdAndLockerId(lockerPeriodId, lockerId); - } - - @Override - public Set findAppliedLockerIds(Long lockerPeriodId) { - return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); - } - - @Override - public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { - return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); - } - - @Override - public LockerApplication saveApplication(LockerApplication application) { - try { - // 제약 위반을 이 자리에서 잡으려면 커밋 시점까지 미루지 않고 바로 INSERT를 내보내야 한다 - return lockerApplicationJpaRepository.saveAndFlush(LockerApplicationJpaEntity.from(application)) - .toDomain(); - } catch (DataIntegrityViolationException e) { - if (isViolated(e, LOCKER_UNIQUE_CONSTRAINT)) { - throw new BusinessException(LockerErrorCode.LOCKER_ALREADY_ASSIGNED); - } - throw e; - } - } - - /** MySQL은 제약 이름 앞에 테이블명을 붙여 줄 수 있어 포함 여부로 비교한다. */ - private boolean isViolated(DataIntegrityViolationException e, String constraintName) { - return e.getCause() instanceof ConstraintViolationException violation - && violation.getConstraintName() != null - && violation.getConstraintName().contains(constraintName); - } }