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() + ); + } +} 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/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/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 9aa40667..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,10 +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.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +/** + * 사물함 운영 회차·구역·사물함 저장소. 사물함 신청은 {@link LockerApplicationRepository}가 맡는다. + */ public interface LockerRepository { /** @@ -15,6 +18,11 @@ public interface LockerRepository { boolean existsSection(Long sectionId); + /** + * 게시된 운영 회차 한 건. 아직 공개하지 않은 회차는 없는 것으로 본다. + */ + Optional findPublishedPeriodById(Long lockerPeriodId); + /** * 전체 구역을 식별자 오름차순으로 조회한다. */ @@ -34,14 +42,4 @@ public interface LockerRepository { * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다. */ Optional findLockerById(Long lockerId); - - /** - * 해당 운영 회차에 이미 신청된 사물함 식별자. - */ - Set findAppliedLockerIds(Long lockerPeriodId); - - /** - * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. - */ - Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/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..53a2c045 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImpl.java @@ -0,0 +1,58 @@ +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.LockerApplicationRepository; +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; + private final LockerApplicationRepository lockerApplicationRepository; + + @Override + @Transactional + public LockerApplicationResult apply(Long memberId, LockerApplyCommand command) { + LockerPeriod period = getPublishedPeriod(command.lockerPeriodId()); + Locker locker = getSelectableLocker(period.getId(), command.lockerId()); + + LockerApplication application = lockerApplicationRepository.save( + 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(lockerId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사물함입니다. lockerId=" + 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 new file mode 100644 index 00000000..ef130354 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java @@ -0,0 +1,289 @@ +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.LockerApplicationRepository; +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 LockerApplicationServiceImpl service(FakeLockerRepository repository) { + return new LockerApplicationServiceImpl(repository, repository.applications); + } + + 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 = service(repository).apply(MEMBER_ID, command()); + LocalDateTime after = LocalDateTime.now(); + + LockerApplication saved = repository.applications.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(FakeLockerApplicationRepository.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); + + service(repository).apply(MEMBER_ID, command()); + + assertEquals(1, repository.applications.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.applications.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.applications.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.applications.saved.size()); + } + + @Test + @DisplayName("없는 사물함은 정상 흐름에서 들어올 수 없어 전용 에러 코드 없이 끊는다") + void missingLocker() { + FakeLockerRepository repository = new FakeLockerRepository() + .withPeriod(period()); + + assertThrows( + IllegalArgumentException.class, + () -> service(repository).apply(MEMBER_ID, command()) + ); + assertEquals(0, repository.applications.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, + () -> service(repository).apply(MEMBER_ID, command()) + ); + assertEquals(expected, exception.getErrorCode()); + } + } + + /** + * 신청에 쓰는 회차·사물함 조회만 답하는 가짜 레포지토리. + *

+ * 신청 저장·조회는 {@link LockerApplicationRepository}로 나뉘어 있지만, 한 번에 준비할 수 있도록 그 가짜를 함께 들고 있다. + */ + private static final class FakeLockerRepository implements LockerRepository { + + private final FakeLockerApplicationRepository applications = new FakeLockerApplicationRepository(); + private LockerPeriod period; + private Locker locker; + + FakeLockerRepository withPeriod(LockerPeriod value) { + this.period = value; + return this; + } + + FakeLockerRepository withLocker(Locker value) { + this.locker = value; + return this; + } + + FakeLockerRepository withAppliedLocker(Long lockerPeriodId, Long lockerId) { + applications.appliedPeriodLockerIds.add(List.of(lockerPeriodId, lockerId)); + return this; + } + + /** 사전 검사와 저장 사이에 다른 신청이 먼저 커밋되어 유니크 제약에 걸린 상황. */ + FakeLockerRepository withLosingSave() { + applications.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)); + } + + // 아래는 구역·배치 조회(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(); + } + } + + 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) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, 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 d1262c57..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 @@ -10,11 +10,14 @@ 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; 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; @@ -39,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); } @@ -59,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()); @@ -76,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()); @@ -89,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()); } @@ -109,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()); } @@ -120,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()); } @@ -131,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()); } @@ -149,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()); @@ -162,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 @@ -173,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); @@ -204,7 +215,7 @@ FakeLockerRepository withSectionLockers(Locker... values) { } FakeLockerRepository withAppliedLockerIds(Long... values) { - this.appliedLockerIds = Set.of(values); + applications.appliedLockerIds = Set.of(values); return this; } @@ -214,7 +225,7 @@ FakeLockerRepository withMyLocker(Locker value) { /** 사물함 목록에 없는 식별자를 넣으면 신청 행만 남고 사물함은 삭제된 상황이 된다. */ FakeLockerRepository withMyLockerId(Long value) { - this.myLockerId = value; + applications.myLockerId = value; return this; } @@ -260,6 +271,18 @@ public Optional findLockerById(Long lockerId) { .findFirst(); } + // 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + @Override + public Optional findPublishedPeriodById(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + } + + private static final class FakeLockerApplicationRepository implements LockerApplicationRepository { + + private Set appliedLockerIds = Set.of(); + private Long myLockerId; + @Override public Set findAppliedLockerIds(Long lockerPeriodId) { return appliedLockerIds; @@ -269,5 +292,17 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return Optional.ofNullable(myLockerId); } + + // 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다 + + @Override + public boolean existsByLocker(Long lockerPeriodId, Long lockerId) { + throw new UnsupportedOperationException(); + } + + @Override + public LockerApplication save(LockerApplication application) { + throw new UnsupportedOperationException(); + } } } 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/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/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/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..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,8 +2,8 @@ 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.LockerPeriod; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import lombok.RequiredArgsConstructor; @@ -16,7 +16,6 @@ public class LockerRepositoryImpl implements LockerRepository { private final LockerPeriodJpaRepository lockerPeriodJpaRepository; private final LockerSectionJpaRepository lockerSectionJpaRepository; private final LockerJpaRepository lockerJpaRepository; - private final LockerApplicationJpaRepository lockerApplicationJpaRepository; @Override public boolean existsPublishedPeriod(Long lockerPeriodId) { @@ -28,6 +27,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() @@ -54,14 +59,4 @@ public Optional findLockerById(Long lockerId) { return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId) .map(LockerJpaEntity::toDomain); } - - @Override - public Set findAppliedLockerIds(Long lockerPeriodId) { - return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); - } - - @Override - public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { - return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); - } } diff --git a/infrastructure/db/src/main/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);