From 32a694fde5e6c1bd7dbfd488ca5e97660c6d8e12 Mon Sep 17 00:00:00 2001 From: EunChanNam <75837025+EunChanNam@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:44:22 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EB=A7=8C=EB=A3=8C=20=EC=98=88?= =?UTF-8?q?=EB=A7=A4=EB=8C=80=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B5=AC=ED=98=84=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 예약대기시 확정좌석에 사용되는 ConfirmedSeat 엔티티 추가(기존 WaitingBookingSeat 추상화해서 적용) * 기존의 WaitingBookingSeat 은 SelectedSeat 으로 전환 * feat : WaitingBooking - toActive, getConfirmedSeatIds 메소드 정의 & Confirmed Seat 연관관계 적용 * feat : WaitingBookingRepository - findWithConfirmedSeatsByStatus, updateStatusByIdIn 쿼리 구현 * feat : WaitingBookingFixture 데이터 추가 * feat : activateWaitingBooking 메소드 변경감지를 통한 업데이트로 변경 & getWaitingBookingsByStatusIsActivation, expireActiveWaitingBooking 메소드 구현 * feat : WaitingBookingFacade - 6시간동안 예약을 하지않아 만료된 예약대기를 처리하는 processWaitingBooking 메소드 구현 * chore : 스케줄러 모듈에 트랜잭션 테스트 의존성 추가 * feat : WaitingBookingFacade - 예매대기 만료처리 스케줄러 구현 * refactor : SelectedSeat 엔티티에 불필요한 게터 제거 * chore : scheduler 모듈 yml 파일 test 전용 파일만 공개하도록 설정 * feat : SeatRepository - id 에 해당하는 좌석의 공연 이름을 조회하는 쿼리 구현 * chore : 스프링 메일 설정추가 & 비동기 이벤트에 사용할 스레드풀 설정 * feat : 메일에 사용할 html 템플릿 구현 * feat : MailSender 구현 & 예약대기 활성화 알림 메일발송 메소드 구현 * refactor : SeatRepository - findShowNameById 리턴타입 Optional 로 변경 * feat : 예약대기 활성화 이벤트를 수신해서 메일을 발송하는 이벤트 리스너 구현 * feat : 예약대기 활성화 알림 메일발송 이벤트 적용 --- .gitignore | 1 + .../domain/repository/SeatRepository.java | 2 + .../hooon/show/exception/ShowErrorCode.java | 3 +- .../adaptor/SeatRepositoryAdaptor.java | 4 ++ .../repository/SeatJpaRepository.java | 4 ++ .../application/WaitingBookingService.java | 25 +++++-- .../facade/WaitingBookingFacade.java | 47 +++++++++--- .../domain/entity/WaitingBooking.java | 45 ++++++++++-- .../waitingbookingseat/ConfirmedSeat.java | 22 ++++++ .../waitingbookingseat/SelectedSeat.java | 23 ++++++ .../WaitingBookingSeat.java | 22 +++--- .../repository/WaitingBookingRepository.java | 11 ++- .../event/WaitingBookingActiveEvent.java | 8 +++ .../exception/WaitingBookingErrorCode.java | 4 +- .../WaitingBookingRepositoryAdaptor.java | 15 +++- .../WaitingBookingJpaRepository.java | 12 +++- .../domain/repository/SeatRepositoryTest.java | 37 ++++++++++ .../WaitingBookingServiceTest.java | 6 +- .../facade/WaitingBookingFacadeTest.java | 34 ++++++++- .../domain/entity/WaitingBookingTest.java | 69 ++++++++++++++++-- .../WaitingBookingRepositoryTest.java | 37 ++++++++-- .../dev/hooon/common/fixture/SeatFixture.java | 16 +++++ .../common/fixture/WaitingBookingFixture.java | 28 ++++++++ scheduler/build.gradle | 4 ++ .../dev/hooon/common/config/MailConfig.java | 15 ++++ .../hooon/common/config/SchedulerConfig.java | 24 +++---- .../main/java/dev/hooon/mail/MailSender.java | 57 +++++++++++++++ .../hooon/mail/dto/WaitingBookingMailDto.java | 8 +++ .../WaitingBookingMailEventListener.java | 40 +++++++++++ .../scheduler/WaitingBookingScheduler.java | 7 +- .../templates/waitingBookingNotification.html | 14 ++++ .../java/dev/hooon/mail/MailSenderTest.java | 48 +++++++++++++ .../WaitingBookingMailEventListenerTest.java | 71 +++++++++++++++++++ .../WaitingBookingSchedulerTest.java | 71 ++++++++++++++++++- .../{main => test}/resources/application.yml | 13 ++++ 35 files changed, 776 insertions(+), 71 deletions(-) create mode 100644 core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/ConfirmedSeat.java create mode 100644 core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/SelectedSeat.java rename core/src/main/java/dev/hooon/waitingbooking/domain/entity/{ => waitingbookingseat}/WaitingBookingSeat.java (72%) create mode 100644 core/src/main/java/dev/hooon/waitingbooking/event/WaitingBookingActiveEvent.java create mode 100644 scheduler/src/main/java/dev/hooon/common/config/MailConfig.java create mode 100644 scheduler/src/main/java/dev/hooon/mail/MailSender.java create mode 100644 scheduler/src/main/java/dev/hooon/mail/dto/WaitingBookingMailDto.java create mode 100644 scheduler/src/main/java/dev/hooon/mail/event/WaitingBookingMailEventListener.java create mode 100644 scheduler/src/main/resources/templates/waitingBookingNotification.html create mode 100644 scheduler/src/test/java/dev/hooon/mail/MailSenderTest.java create mode 100644 scheduler/src/test/java/dev/hooon/mail/event/WaitingBookingMailEventListenerTest.java rename scheduler/src/{main => test}/resources/application.yml (51%) diff --git a/.gitignore b/.gitignore index 5ff40d05..eae1aa4d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ .DS_Store core/src/main/resources/application.yml +scheduler/src/main/resources/application.yml ### STS ### .apt_generated diff --git a/core/src/main/java/dev/hooon/show/domain/repository/SeatRepository.java b/core/src/main/java/dev/hooon/show/domain/repository/SeatRepository.java index bafd6006..f65dde90 100644 --- a/core/src/main/java/dev/hooon/show/domain/repository/SeatRepository.java +++ b/core/src/main/java/dev/hooon/show/domain/repository/SeatRepository.java @@ -24,6 +24,8 @@ public interface SeatRepository { void updateStatusByIdIn(Collection ids, SeatStatus status); + Optional findShowNameById(Long id); + List findSeatInfoByShowIdAndDateAndRound( Long showId, LocalDate date, diff --git a/core/src/main/java/dev/hooon/show/exception/ShowErrorCode.java b/core/src/main/java/dev/hooon/show/exception/ShowErrorCode.java index a6bd83e5..04c8c5e2 100644 --- a/core/src/main/java/dev/hooon/show/exception/ShowErrorCode.java +++ b/core/src/main/java/dev/hooon/show/exception/ShowErrorCode.java @@ -8,7 +8,8 @@ @RequiredArgsConstructor public enum ShowErrorCode implements ErrorCode { - SHOW_NOT_FOUND("공연을 찾을 수 없습니다.", "S_001"); + SHOW_NOT_FOUND("공연을 찾을 수 없습니다.", "S_001"), + SHOW_NAME_NOT_FOUND("좌석에 해당하는 공연의 이름을 찾을 수 없습니다", "S_002"); private final String message; private final String code; diff --git a/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java b/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java index fe0f0dbe..a9bed623 100644 --- a/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java +++ b/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java @@ -49,6 +49,10 @@ public void updateStatusByIdIn(Collection ids, SeatStatus status) { } @Override + public Optional findShowNameById(Long id) { + return seatJpaRepository.findShowNameById(id); + } + public List findSeatInfoByShowIdAndDateAndRound( Long showId, LocalDate date, int round ) { diff --git a/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java b/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java index faa6cf37..c9a1e64b 100644 --- a/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java +++ b/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -34,6 +35,9 @@ public interface SeatJpaRepository extends JpaRepository { @Query("update Seat s SET s.seatStatus = :status where s.id in :ids") void updateStatusByIdIn(@Param("ids") Collection ids, @Param("status") SeatStatus status); + @Query("select show.name from Seat s left join Show show on show.id = s.show.id where s.id = :id") + Optional findShowNameById(@Param("id") Long id); + @Query(""" select new dev.hooon.show.dto.query.seats.SeatsInfoDto(seat.seatGrade, COUNT(seat), seat.price) diff --git a/core/src/main/java/dev/hooon/waitingbooking/application/WaitingBookingService.java b/core/src/main/java/dev/hooon/waitingbooking/application/WaitingBookingService.java index 5237b9bf..61a6157d 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/application/WaitingBookingService.java +++ b/core/src/main/java/dev/hooon/waitingbooking/application/WaitingBookingService.java @@ -1,5 +1,6 @@ package dev.hooon.waitingbooking.application; +import java.util.Collection; import java.util.List; import org.springframework.stereotype.Service; @@ -7,6 +8,7 @@ import dev.hooon.user.domain.entity.User; import dev.hooon.waitingbooking.domain.entity.WaitingBooking; +import dev.hooon.waitingbooking.domain.entity.WaitingStatus; import dev.hooon.waitingbooking.domain.repository.WaitingBookingRepository; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; import lombok.RequiredArgsConstructor; @@ -24,14 +26,27 @@ public WaitingBooking createWaitingBooking(User user, WaitingRegisterRequest req return waitingBooking; } - // 대기 상태인 WaitingBooking 조회 + // 대기 상태인 WaitingBooking 조회(fetch join selectedSeat + user) public List getWaitingBookingsByStatusIsWaiting() { - return waitingBookingRepository.findByStatusIsWaiting(); + return waitingBookingRepository.findWithSelectedSeatsByStatus(WaitingStatus.WAITING); } - // ID 에 해당하는 WaitingBooking ACTIVATION 상태로 변경하고 expireAt 6시간뒤로 설정 + // WaitingBooking 상태를 ACTIVATION 상태로 변경하고 expiredAt 6시간뒤로 설정한 후 확정 좌석정보를 저장 @Transactional - public void activateWaitingBooking(Long waitingBookingId) { - waitingBookingRepository.updateToActiveById(waitingBookingId); + public void activateWaitingBooking(Long waitingBookingId, List confirmedSeatIds) { + waitingBookingRepository.findById(waitingBookingId) + .ifPresent(waitingBooking -> waitingBooking.toActive(confirmedSeatIds)); + } + + // 활성 상태인 WaitingBooking 조회(fetch join confirmedSeat) + @Transactional(readOnly = true) + public List getWaitingBookingsByStatusIsActivation() { + return waitingBookingRepository.findWithConfirmedSeatsByStatus(WaitingStatus.ACTIVATION); + } + + // 활성 상태인 WaitingBooking 을 만료상태로 바꿈 + @Transactional + public void expireActiveWaitingBooking(Collection targetIds) { + waitingBookingRepository.updateStatusByIdIn(WaitingStatus.EXPIRED, targetIds); } } diff --git a/core/src/main/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacade.java b/core/src/main/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacade.java index d662beb3..4b09e28f 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacade.java +++ b/core/src/main/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacade.java @@ -1,9 +1,11 @@ package dev.hooon.waitingbooking.application.facade; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Set; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import dev.hooon.show.application.SeatService; @@ -13,6 +15,7 @@ import dev.hooon.waitingbooking.domain.entity.WaitingBooking; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; import dev.hooon.waitingbooking.dto.response.WaitingRegisterResponse; +import dev.hooon.waitingbooking.event.WaitingBookingActiveEvent; import lombok.RequiredArgsConstructor; @Component @@ -22,6 +25,7 @@ public class WaitingBookingFacade { private final WaitingBookingService waitingBookingService; private final UserService userService; private final SeatService seatService; + private final ApplicationEventPublisher eventPublisher; // 선택한 좌석중에서 취소좌석에 포함되는 좌석 ID 를 LIST 로 응답 private List fetchMatchingSeatIds( @@ -41,6 +45,7 @@ private List fetchMatchingSeatIds( return matchSeatIds; } + // 예약대기 등록 public WaitingRegisterResponse registerWaitingBooking(Long userId, WaitingRegisterRequest request) { User user = userService.getUserById(userId); WaitingBooking waitingBooking = waitingBookingService.createWaitingBooking(user, request); @@ -48,36 +53,56 @@ public WaitingRegisterResponse registerWaitingBooking(Long userId, WaitingRegist return new WaitingRegisterResponse(waitingBooking.getId()); } + // 예약대기 처리 public void processWaitingBooking() { // 1. 취소된 좌석을 모두 조회한다 (PK Set 으로) Set canceledSeatIds = seatService.getCanceledSeatIds(); // 2. 대기중 상태인 예약대기 목록을 날짜순으로 조회한다 List waitingList = waitingBookingService.getWaitingBookingsByStatusIsWaiting(); // 3. waitingList 반복하면서 아래 작업을 수행 - /* - * (1) 대기목록에 포함된 좌석의 PK 가 취소된 좌석 Set 에 존재하는지 확인 - * (2) 대기목록의 좌석중에서 취소목록에 포함되는 좌석 아이디 가져옴 - * (3) 가져온 좌석 아이디 사이즈가 선택좌석개수와 같으면 취소좌석 SET 에서 해당 PK 지우고 좌석 상태를 취소에서 대기로 바꾸고 예약대기를 활성화 - * + 사용자에게 메일 발송 (구현 예정) - */ waitingList.forEach(waitingBooking -> { - // (1) + // (1) 대기목록에 포함된 좌석의 PK 가 취소된 좌석 Set 에 존재하는지 확인 List selectedSeatIds = waitingBooking.getSelectedSeatIds(); - // (2) + // (2) 대기목록의 좌석중에서 취소목록에 포함되는 좌석 아이디 가져옴 List matchSeatIds = fetchMatchingSeatIds( canceledSeatIds, selectedSeatIds, waitingBooking.getSeatCount() ); - // (3) + // (3) 가져온 좌석 아이디 사이즈가 선택좌석개수와 같으면 취소좌석 SET 에서 해당 PK 지우고 좌석 상태를 취소에서 대기로 바꾸고 예약대기를 활성화 if (matchSeatIds.size() == waitingBooking.getSeatCount()) { matchSeatIds.forEach(canceledSeatIds::remove); seatService.updateSeatToWaiting(matchSeatIds); - waitingBookingService.activateWaitingBooking(waitingBooking.getId()); - // 메일 알림 이벤트 발행 + waitingBookingService.activateWaitingBooking(waitingBooking.getId(), matchSeatIds); + // (4) 사용자에게 메일 발송 + eventPublisher.publishEvent(new WaitingBookingActiveEvent( + waitingBooking.getUser().getName(), + waitingBooking.getUser().getEmail(), + matchSeatIds.get(0)) + ); } }); // 4. 반복이 끝났는데 남아있는 취소 좌석들은 예약가능 상태로 변경 seatService.updateSeatToAvailable(canceledSeatIds); } + + // 6시간동안 예약을 하지않아 만료된 예약대기를 처리 + public void processExpiredWaitingBooking() { + List waitingBookings = waitingBookingService.getWaitingBookingsByStatusIsActivation(); + + List expiredWaitingBookingIds = new ArrayList<>(); + List expiredSeatIds = new ArrayList<>(); + waitingBookings.forEach(waitingBooking -> { + // 만료된 예약대기라면 만료리스트에 추가 + if (waitingBooking.getExpiredAt().isBefore(LocalDateTime.now())) { + expiredWaitingBookingIds.add(waitingBooking.getId()); + expiredSeatIds.addAll(waitingBooking.getConfirmedSeatIds()); + } + }); + + if (!expiredWaitingBookingIds.isEmpty()) { + waitingBookingService.expireActiveWaitingBooking(expiredWaitingBookingIds); + seatService.updateSeatToAvailable(expiredSeatIds); + } + } } diff --git a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBooking.java b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBooking.java index 183b82bc..a0b4d7e3 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBooking.java +++ b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBooking.java @@ -16,6 +16,8 @@ import dev.hooon.common.entity.TimeBaseEntity; import dev.hooon.common.exception.ValidationException; import dev.hooon.user.domain.entity.User; +import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.ConfirmedSeat; +import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.SelectedSeat; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.ForeignKey; @@ -56,7 +58,10 @@ public class WaitingBooking extends TimeBaseEntity { private LocalDateTime expiredAt; @OneToMany(mappedBy = "waitingBooking", cascade = {REMOVE, PERSIST}) - List waitingBookingSeats = new ArrayList<>(); + List selectedSeats = new ArrayList<>(); + + @OneToMany(mappedBy = "waitingBooking", cascade = {REMOVE, PERSIST}) + List confirmedSeats = new ArrayList<>(); // 생성 메소드 private WaitingBooking( @@ -71,13 +76,13 @@ private WaitingBooking( this.status = WaitingStatus.WAITING; this.seatCount = seatCount; this.user = user; - applyWaitingBookingSeats(seatIds); + applySelectedSeats(seatIds); } - private void applyWaitingBookingSeats(List seatIds) { + private void applySelectedSeats(List seatIds) { seatIds.forEach(seatId -> { - WaitingBookingSeat waitingBookingSeat = WaitingBookingSeat.of(seatId, this); - this.waitingBookingSeats.add(waitingBookingSeat); + SelectedSeat selectedSeat = SelectedSeat.of(seatId, this); + this.selectedSeats.add(selectedSeat); }); } @@ -103,6 +108,12 @@ private void validateSelectedSeats(int seatCount, List seatIds) { } } + private void validateConfirmedSeats(List seatIds) { + if (seatIds.size() != seatCount) { + throw new ValidationException(INVALID_CONFIRMED_SEAT_COUNT); + } + } + // 팩토리 메소드 public static WaitingBooking of( User user, @@ -112,9 +123,29 @@ public static WaitingBooking of( return new WaitingBooking(user, seatCount, seatIds); } + // 대기 등록시 선택한 좌석의 ID 조회 public List getSelectedSeatIds() { - return waitingBookingSeats.stream() - .map(WaitingBookingSeat::getSeatId) + return selectedSeats.stream() + .map(SelectedSeat::getSeatId) + .toList(); + } + + // 확정된 좌석의 ID 조회 + public List getConfirmedSeatIds() { + return confirmedSeats.stream() + .map(ConfirmedSeat::getSeatId) .toList(); } + + // activation 상태로 전환 + 만료시간 6시간 뒤로 설정 + ConfirmedSeat 엔티티 추가 + public void toActive(List seatIds) { + this.status = WaitingStatus.ACTIVATION; + this.expiredAt = LocalDateTime.now().plusHours(6); + addConfirmedSeats(seatIds); + } + + private void addConfirmedSeats(List seatIds) { + validateConfirmedSeats(seatIds); + seatIds.forEach(seatId -> this.confirmedSeats.add(ConfirmedSeat.of(seatId, this))); + } } diff --git a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/ConfirmedSeat.java b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/ConfirmedSeat.java new file mode 100644 index 00000000..ebea0e38 --- /dev/null +++ b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/ConfirmedSeat.java @@ -0,0 +1,22 @@ +package dev.hooon.waitingbooking.domain.entity.waitingbookingseat; + +import dev.hooon.waitingbooking.domain.entity.WaitingBooking; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Table(name = "confirmed_seat_table") +@DiscriminatorValue("confirmed") +public class ConfirmedSeat extends WaitingBookingSeat { + + private ConfirmedSeat(Long seatId, WaitingBooking waitingBooking) { + super(seatId, waitingBooking); + } + + public static ConfirmedSeat of(Long seatId, WaitingBooking waitingBooking) { + return new ConfirmedSeat(seatId, waitingBooking); + } +} diff --git a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/SelectedSeat.java b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/SelectedSeat.java new file mode 100644 index 00000000..f025b5e4 --- /dev/null +++ b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/SelectedSeat.java @@ -0,0 +1,23 @@ +package dev.hooon.waitingbooking.domain.entity.waitingbookingseat; + +import dev.hooon.waitingbooking.domain.entity.WaitingBooking; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Table(name = "selected_seat_table") +@DiscriminatorValue("selected") +public class SelectedSeat extends WaitingBookingSeat { + + private SelectedSeat(Long seatId, WaitingBooking waitingBooking) { + super(seatId, waitingBooking); + } + + // 팩토리 메소드 + public static SelectedSeat of(Long seatId, WaitingBooking waitingBooking) { + return new SelectedSeat(seatId, waitingBooking); + } +} diff --git a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingSeat.java b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/WaitingBookingSeat.java similarity index 72% rename from core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingSeat.java rename to core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/WaitingBookingSeat.java index 9abef8e5..29e9b128 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingSeat.java +++ b/core/src/main/java/dev/hooon/waitingbooking/domain/entity/waitingbookingseat/WaitingBookingSeat.java @@ -1,4 +1,4 @@ -package dev.hooon.waitingbooking.domain.entity; +package dev.hooon.waitingbooking.domain.entity.waitingbookingseat; import static dev.hooon.common.exception.CommonValidationError.*; import static jakarta.persistence.ConstraintMode.*; @@ -7,27 +7,31 @@ import org.springframework.util.Assert; +import dev.hooon.waitingbooking.domain.entity.WaitingBooking; import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter -@Table(name = "waiting_booking_seat_table") @NoArgsConstructor -public class WaitingBookingSeat { +@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) +@DiscriminatorColumn +public abstract class WaitingBookingSeat { private static final String WAITING_BOOKING_SEAT = "waitingBookingSeat"; @Id - @GeneratedValue(strategy = IDENTITY) + @GeneratedValue(strategy = AUTO) @Column(name = "waiting_booking_seat_id") private Long id; @@ -42,15 +46,11 @@ public class WaitingBookingSeat { private WaitingBooking waitingBooking; // 생성 메소드 - private WaitingBookingSeat(Long seatId, WaitingBooking waitingBooking) { + protected WaitingBookingSeat(Long seatId, WaitingBooking waitingBooking) { Assert.notNull(seatId, getNotNullMessage(WAITING_BOOKING_SEAT, "seatId")); Assert.notNull(waitingBooking, getNotNullMessage(WAITING_BOOKING_SEAT, "waitingBooking")); this.seatId = seatId; this.waitingBooking = waitingBooking; } - - // 팩토리 메소드 - public static WaitingBookingSeat of(Long seatId, WaitingBooking waitingBooking) { - return new WaitingBookingSeat(seatId, waitingBooking); - } } + diff --git a/core/src/main/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepository.java b/core/src/main/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepository.java index 8705dbf1..f4b54418 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepository.java +++ b/core/src/main/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepository.java @@ -1,9 +1,11 @@ package dev.hooon.waitingbooking.domain.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import dev.hooon.waitingbooking.domain.entity.WaitingBooking; +import dev.hooon.waitingbooking.domain.entity.WaitingStatus; public interface WaitingBookingRepository { @@ -13,8 +15,13 @@ public interface WaitingBookingRepository { List findAll(); - // WaitingStatus 가 WAITING 인 데이터를 최신순으로 조회하는 쿼리 - List findByStatusIsWaiting(); + // WaitingStatus 가 WAITING 인 데이터를 최신순으로 조회하는 쿼리(fetch join selectedSeat + user) + List findWithSelectedSeatsByStatus(WaitingStatus status); + + // WaitingStatus 가 WAITING 인 데이터를 최신순으로 조회하는 쿼리 (fetch join confirmedSeat) + List findWithConfirmedSeatsByStatus(WaitingStatus status); void updateToActiveById(Long id); + + void updateStatusByIdIn(WaitingStatus status, Collection targetIds); } diff --git a/core/src/main/java/dev/hooon/waitingbooking/event/WaitingBookingActiveEvent.java b/core/src/main/java/dev/hooon/waitingbooking/event/WaitingBookingActiveEvent.java new file mode 100644 index 00000000..cbf67fad --- /dev/null +++ b/core/src/main/java/dev/hooon/waitingbooking/event/WaitingBookingActiveEvent.java @@ -0,0 +1,8 @@ +package dev.hooon.waitingbooking.event; + +public record WaitingBookingActiveEvent( + String nickname, + String email, + Long seatId +) { +} diff --git a/core/src/main/java/dev/hooon/waitingbooking/exception/WaitingBookingErrorCode.java b/core/src/main/java/dev/hooon/waitingbooking/exception/WaitingBookingErrorCode.java index 71c8ee87..d61fdf1b 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/exception/WaitingBookingErrorCode.java +++ b/core/src/main/java/dev/hooon/waitingbooking/exception/WaitingBookingErrorCode.java @@ -10,7 +10,9 @@ public enum WaitingBookingErrorCode implements ErrorCode { INVALID_SEAT_COUNT("좌석 개수는 1~3 개 내로 선택해야합니다", "W_001"), EMPTY_SELECTED_SEAT("좌석은 반드시 1개 이상 선택해야합니다", "W_002"), - INVALID_SELECTED_SEAT_COUNT("좌석은 선택한 좌석 개수에서 10배수 까지 선택 가능합니다", "W_003"); + INVALID_SELECTED_SEAT_COUNT("좌석은 선택한 좌석 개수에서 10배수 까지 선택 가능합니다", "W_003"), + INVALID_CONFIRMED_SEAT_COUNT("확정 좌석 개수는 좌석개수와 동일해야합니다", "W_004"), + NOT_FOUND_BY_ID("id 에 해당하는 WaitingBooking 이 존재하지 않습니다", "W_005"); private final String message; private final String code; diff --git a/core/src/main/java/dev/hooon/waitingbooking/infrastructure/adaptor/WaitingBookingRepositoryAdaptor.java b/core/src/main/java/dev/hooon/waitingbooking/infrastructure/adaptor/WaitingBookingRepositoryAdaptor.java index 1d46e5e7..8572cba3 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/infrastructure/adaptor/WaitingBookingRepositoryAdaptor.java +++ b/core/src/main/java/dev/hooon/waitingbooking/infrastructure/adaptor/WaitingBookingRepositoryAdaptor.java @@ -1,6 +1,7 @@ package dev.hooon.waitingbooking.infrastructure.adaptor; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -34,8 +35,13 @@ public List findAll() { } @Override - public List findByStatusIsWaiting() { - return waitingBookingJpaRepository.findByStatusOrderByIdDesc(WaitingStatus.WAITING); + public List findWithSelectedSeatsByStatus(WaitingStatus status) { + return waitingBookingJpaRepository.findWithSelectedSeatsByStatusOrderByIdDesc(status); + } + + @Override + public List findWithConfirmedSeatsByStatus(WaitingStatus status) { + return waitingBookingJpaRepository.findWithConfirmedSeatsByStatusOrderByIdDesc(status); } @Override @@ -46,4 +52,9 @@ public void updateToActiveById(Long id) { LocalDateTime.now().plusHours(6) ); } + + @Override + public void updateStatusByIdIn(WaitingStatus status, Collection targetIds) { + waitingBookingJpaRepository.updateStatusByIdIn(status, targetIds); + } } diff --git a/core/src/main/java/dev/hooon/waitingbooking/infrastructure/repository/WaitingBookingJpaRepository.java b/core/src/main/java/dev/hooon/waitingbooking/infrastructure/repository/WaitingBookingJpaRepository.java index b3fab660..2b95b8a3 100644 --- a/core/src/main/java/dev/hooon/waitingbooking/infrastructure/repository/WaitingBookingJpaRepository.java +++ b/core/src/main/java/dev/hooon/waitingbooking/infrastructure/repository/WaitingBookingJpaRepository.java @@ -1,6 +1,7 @@ package dev.hooon.waitingbooking.infrastructure.repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; @@ -14,8 +15,11 @@ public interface WaitingBookingJpaRepository extends JpaRepository { - @EntityGraph(attributePaths = {"user", "waitingBookingSeats"}) - List findByStatusOrderByIdDesc(WaitingStatus status); + @EntityGraph(attributePaths = {"user", "selectedSeats"}) + List findWithSelectedSeatsByStatusOrderByIdDesc(WaitingStatus status); + + @EntityGraph(attributePaths = {"confirmedSeats"}) + List findWithConfirmedSeatsByStatusOrderByIdDesc(WaitingStatus status); @Modifying @Query("update WaitingBooking w SET w.status = :status, w.expiredAt = :expireAt where w.id = :id") @@ -23,4 +27,8 @@ void updateStatusAndExpireAt( @Param("id") Long id, @Param("status") WaitingStatus status, @Param("expireAt") LocalDateTime expireAt); + + @Modifying + @Query("update WaitingBooking w SET w.status = :status where w.id in :ids") + void updateStatusByIdIn(@Param("status") WaitingStatus status, @Param("ids") Collection ids); } diff --git a/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java b/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java index a9d3f295..a2cafc40 100644 --- a/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java +++ b/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java @@ -14,6 +14,11 @@ import dev.hooon.common.fixture.SeatFixture; import dev.hooon.common.support.DataJpaTestSupport; +import dev.hooon.show.domain.entity.Show; +import dev.hooon.show.domain.entity.ShowCategory; +import dev.hooon.show.domain.entity.ShowPeriod; +import dev.hooon.show.domain.entity.ShowTime; +import dev.hooon.show.domain.entity.place.Place; import dev.hooon.show.domain.entity.seat.Seat; import dev.hooon.show.domain.entity.seat.SeatStatus; import dev.hooon.show.dto.query.SeatDateRoundDto; @@ -25,6 +30,10 @@ class SeatRepositoryTest extends DataJpaTestSupport { @Autowired private SeatRepository seatRepository; + @Autowired + private PlaceRepository placeRepository; + @Autowired + private ShowRepository showRepository; @PersistenceContext private EntityManager entityManager; @@ -108,4 +117,32 @@ void updateStatusByIdInTest() { .hasSameSizeAs(seatIds) .containsAll(seatIds); } + + @Test + @DisplayName("[id 에 해당하는 좌석의 공연 이름을 조회한다]") + void findShowNameById_test() { + //given + Place place = new Place("placeName", null, "address", null); + Show show = new Show( + "show", + ShowCategory.CONCERT, + new ShowPeriod(LocalDate.now(), LocalDate.now().plusMonths(1)), + new ShowTime(100, 10), + "청불", + 100, + place + ); + + placeRepository.save(place); + showRepository.save(show); + + Seat seat = SeatFixture.getSeat(show); + seatRepository.saveAll(List.of(seat)); + + //when + String result = seatRepository.findShowNameById(seat.getId()).orElseThrow(); + + //then + assertThat(result).isEqualTo(show.getName()); + } } \ No newline at end of file diff --git a/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java b/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java index ce4b022f..19e301d9 100644 --- a/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java +++ b/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java @@ -14,7 +14,7 @@ import dev.hooon.user.domain.entity.User; import dev.hooon.waitingbooking.domain.entity.WaitingBooking; -import dev.hooon.waitingbooking.domain.entity.WaitingBookingSeat; +import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.SelectedSeat; import dev.hooon.waitingbooking.domain.entity.WaitingStatus; import dev.hooon.waitingbooking.domain.repository.WaitingBookingRepository; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; @@ -46,8 +46,8 @@ void createWaitingBookingTest() { () -> assertThat(result.getStatus()).isEqualTo(WaitingStatus.WAITING), () -> assertThat(result.getUser()).isEqualTo(user), () -> { - List actualSeatIds = result.getWaitingBookingSeats().stream() - .map(WaitingBookingSeat::getSeatId) + List actualSeatIds = result.getSelectedSeats().stream() + .map(SelectedSeat::getSeatId) .toList(); assertThat(actualSeatIds) .hasSameSizeAs(seatIds) diff --git a/core/src/test/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacadeTest.java b/core/src/test/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacadeTest.java index 5b4ca96c..06dd2be2 100644 --- a/core/src/test/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacadeTest.java +++ b/core/src/test/java/dev/hooon/waitingbooking/application/facade/WaitingBookingFacadeTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -13,6 +14,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import dev.hooon.common.fixture.WaitingBookingFixture; @@ -23,6 +25,7 @@ import dev.hooon.waitingbooking.domain.entity.WaitingBooking; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; import dev.hooon.waitingbooking.dto.response.WaitingRegisterResponse; +import dev.hooon.waitingbooking.event.WaitingBookingActiveEvent; @DisplayName("[WaitingBookingFacade 테스트]") @ExtendWith(MockitoExtension.class) @@ -36,6 +39,8 @@ class WaitingBookingFacadeTest { private UserService userService; @Mock private SeatService seatService; + @Mock + private ApplicationEventPublisher eventPublisher; @Test @DisplayName("[사용자와 예약대기 정보를 통해 예약대기를 등록한다]") @@ -83,8 +88,33 @@ void processWaitingBookingTest() { //then verify(seatService, times(2)).updateSeatToWaiting(anyCollection()); - verify(waitingBookingService, times(1)).activateWaitingBooking(waitingBookings.get(0).getId()); - verify(waitingBookingService, times(1)).activateWaitingBooking(waitingBookings.get(1).getId()); + verify(waitingBookingService, times(1)).activateWaitingBooking(eq(waitingBookings.get(0).getId()), anyList()); + verify(waitingBookingService, times(1)).activateWaitingBooking(eq(waitingBookings.get(1).getId()), anyList()); verify(seatService, times(1)).updateSeatToAvailable(anyCollection()); + + verify(eventPublisher, times(2)).publishEvent(any(WaitingBookingActiveEvent.class)); + } + + @Test + @DisplayName("[만료된 활성화 상태인 예약대기를 처리한다]") + void processExpiredWaitingBooking_test() { + //given + LocalDateTime beforeNow = LocalDateTime.now().minusSeconds(10); + LocalDateTime afterNow = LocalDateTime.now().plusSeconds(10); + List waitingBookings = List.of( + WaitingBookingFixture.getActiveWaitingBooking(1L, beforeNow, 2, List.of(1L, 2L)), + WaitingBookingFixture.getActiveWaitingBooking(2L, beforeNow, 2, List.of(3L, 4L)), + WaitingBookingFixture.getActiveWaitingBooking(3L, afterNow, 2, List.of(5L, 6L)) + ); + + given(waitingBookingService.getWaitingBookingsByStatusIsActivation()) + .willReturn(waitingBookings); + + //when + waitingBookingFacade.processExpiredWaitingBooking(); + + //then + verify(waitingBookingService, times(1)).expireActiveWaitingBooking(anyList()); + verify(seatService, times(1)).updateSeatToAvailable(anyList()); } } \ No newline at end of file diff --git a/core/src/test/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingTest.java b/core/src/test/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingTest.java index 5476222e..06fd7636 100644 --- a/core/src/test/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingTest.java +++ b/core/src/test/java/dev/hooon/waitingbooking/domain/entity/WaitingBookingTest.java @@ -1,5 +1,6 @@ package dev.hooon.waitingbooking.domain.entity; +import static dev.hooon.waitingbooking.exception.WaitingBookingErrorCode.*; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; @@ -13,6 +14,7 @@ import dev.hooon.common.exception.ValidationException; import dev.hooon.user.domain.entity.User; +import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.SelectedSeat; import dev.hooon.waitingbooking.exception.WaitingBookingErrorCode; @DisplayName("[WaitingBooking 테스트]") @@ -35,8 +37,8 @@ void ofTest_1() { () -> assertThat(result.getStatus()).isEqualTo(WaitingStatus.WAITING), () -> assertThat(result.getUser()).isEqualTo(user), () -> { - List actualSeatIds = result.waitingBookingSeats.stream() - .map(WaitingBookingSeat::getSeatId) + List actualSeatIds = result.selectedSeats.stream() + .map(SelectedSeat::getSeatId) .toList(); assertThat(actualSeatIds) .hasSameSizeAs(seatIds) @@ -89,11 +91,11 @@ void ofTest_4() { // 많을 때 assertThatThrownBy(() -> WaitingBooking.of(user, seatCount, overSeatIds)) .isInstanceOf(ValidationException.class) - .hasMessageContaining(WaitingBookingErrorCode.INVALID_SELECTED_SEAT_COUNT.getMessage()); + .hasMessageContaining(INVALID_SELECTED_SEAT_COUNT.getMessage()); // 적을 때 assertThatThrownBy(() -> WaitingBooking.of(user, seatCount, underSeatIds)) .isInstanceOf(ValidationException.class) - .hasMessageContaining(WaitingBookingErrorCode.INVALID_SELECTED_SEAT_COUNT.getMessage()); + .hasMessageContaining(INVALID_SELECTED_SEAT_COUNT.getMessage()); } @Test @@ -115,4 +117,63 @@ void getSelectedSeatIdsTest() { .hasSameSizeAs(selectedSeatIds) .containsAll(selectedSeatIds); } + + @Test + @DisplayName("[확정된 좌석을 예약대기 등록한다]") + void addConfirmedSeats_test_1() { + //given + WaitingBooking waitingBooking = WaitingBooking.of( + new User(), + 2, + List.of(1L, 2L, 3L) + ); + + //when + List seatIds = List.of(1L, 2L); + waitingBooking.toActive(seatIds); + + //then + assertThat(waitingBooking.getConfirmedSeats()).hasSize(2); + waitingBooking.getConfirmedSeats().forEach( + confirmedSeat -> assertThat(seatIds).contains(confirmedSeat.getSeatId()) + ); + } + + @Test + @DisplayName("[확정된 좌석 개수가 예약대기 좌석 개수랑 일치하지않아 실패한다]") + void addConfirmedSeats_test_2() { + //given + WaitingBooking waitingBooking = WaitingBooking.of( + new User(), + 1, + List.of(1L, 2L, 3L) + ); + + //when, then + List seatIds = List.of(1L, 2L); + assertThatThrownBy(() -> waitingBooking.toActive(seatIds)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining(INVALID_CONFIRMED_SEAT_COUNT.getMessage()); + } + + @Test + @DisplayName("[확정된 좌석의 ID List 를 응답한다]") + void getConfirmedSeatIds_test() { + //given + List confirmedSeatIds = List.of(1L, 2L); + WaitingBooking waitingBooking = WaitingBooking.of( + new User(), + 2, + List.of(1L, 2L, 3L, 4L, 5L) + ); + waitingBooking.toActive(confirmedSeatIds); + + //when + List result = waitingBooking.getConfirmedSeatIds(); + + //then + assertThat(result) + .hasSameSizeAs(confirmedSeatIds) + .containsAll(confirmedSeatIds); + } } \ No newline at end of file diff --git a/core/src/test/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepositoryTest.java b/core/src/test/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepositoryTest.java index ec3ebd33..43e1ff43 100644 --- a/core/src/test/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepositoryTest.java +++ b/core/src/test/java/dev/hooon/waitingbooking/domain/repository/WaitingBookingRepositoryTest.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -32,12 +33,15 @@ class WaitingBookingRepositoryTest extends DataJpaTestSupport { private final User user = new User("hello123@naver.com", "name", UserRole.BUYER); + @BeforeEach + void setUp() { + userRepository.save(user); + } + @Test - @DisplayName("[WAITING 상태인 데이터를 최신순으로 조회한다]") + @DisplayName("[WaitingStatus 로 데이터를 최신순으로 조회한다]") void findByStatusIsWaitingTest() { //given - userRepository.save(user); - List waitingBookings = List.of( WaitingBookingFixture.getWaitingBooking(user), WaitingBookingFixture.getWaitingBooking(user), @@ -49,7 +53,7 @@ void findByStatusIsWaitingTest() { waitingBookings.forEach(waitingBooking -> waitingBookingRepository.save(waitingBooking)); //when - List result = waitingBookingRepository.findByStatusIsWaiting(); + List result = waitingBookingRepository.findWithSelectedSeatsByStatus(WaitingStatus.WAITING); //then assertThat(result) @@ -61,8 +65,6 @@ void findByStatusIsWaitingTest() { @DisplayName("[id 에 해당하는 데이터의 status 를 ACTIVATION 으로 변경하고 expireAt 을 6시간뒤로 설정한다]") void updateToActiveByIdTest() { //given - userRepository.save(user); - WaitingBooking waitingBooking = WaitingBookingFixture.getWaitingBooking(user); waitingBookingRepository.save(waitingBooking); @@ -76,4 +78,27 @@ void updateToActiveByIdTest() { assertThat(actual.getStatus()).isEqualTo(WaitingStatus.ACTIVATION); assertThat(actual.getExpiredAt()).isEqualToIgnoringSeconds(LocalDateTime.now().plusHours(6)); } + + @Test + @DisplayName("[id 컬렉션에 포함되는 WaitingBooking 의 상태를 입력한 상태로 변경한다]") + void updateStatusByIdIn_test() { + //given + List waitingBookings = List.of( + WaitingBookingFixture.getWaitingBooking(user), + WaitingBookingFixture.getWaitingBooking(user) + ); + waitingBookings.forEach(waitingBooking -> waitingBookingRepository.save(waitingBooking)); + List waitingBookingIds = waitingBookings.stream().map(WaitingBooking::getId).toList(); + + //when + waitingBookingRepository.updateStatusByIdIn(WaitingStatus.FIN, waitingBookingIds); + entityManager.flush(); + entityManager.clear(); + + //then + List all = waitingBookingRepository.findAll(); + + assertThat(all).hasSize(2); + all.forEach(waitingBooking -> assertThat(waitingBooking.getStatus()).isEqualTo(WaitingStatus.FIN)); + } } \ No newline at end of file diff --git a/core/src/testFixtures/java/dev/hooon/common/fixture/SeatFixture.java b/core/src/testFixtures/java/dev/hooon/common/fixture/SeatFixture.java index 3dd2e3a4..4c9d4f1c 100644 --- a/core/src/testFixtures/java/dev/hooon/common/fixture/SeatFixture.java +++ b/core/src/testFixtures/java/dev/hooon/common/fixture/SeatFixture.java @@ -69,4 +69,20 @@ public static Seat getSeat(Long seatId) { ReflectionTestUtils.setField(seat, "id", seatId); return seat; } + + public static Seat getSeat(Show show) { + return Seat.of( + show, + SeatGrade.VIP, + true, + "1층", + "A", + 10, + 100000, + LocalDate.now(), + 1, + LocalTime.now(), + SeatStatus.AVAILABLE + ); + } } diff --git a/core/src/testFixtures/java/dev/hooon/common/fixture/WaitingBookingFixture.java b/core/src/testFixtures/java/dev/hooon/common/fixture/WaitingBookingFixture.java index 74df7b40..130a0dfa 100644 --- a/core/src/testFixtures/java/dev/hooon/common/fixture/WaitingBookingFixture.java +++ b/core/src/testFixtures/java/dev/hooon/common/fixture/WaitingBookingFixture.java @@ -1,5 +1,6 @@ package dev.hooon.common.fixture; +import java.time.LocalDateTime; import java.util.List; import org.springframework.test.util.ReflectionTestUtils; @@ -33,4 +34,31 @@ public static WaitingBooking getWaitingBooking( ReflectionTestUtils.setField(waitingBooking, "id", waitingBookingId); return waitingBooking; } + + public static WaitingBooking getActiveWaitingBooking( + Long id, + LocalDateTime expiredAt, + int seatCount, + List seatIds + ) { + WaitingBooking waitingBooking = WaitingBooking.of(new User(), seatCount, seatIds); + waitingBooking.toActive(seatIds); + ReflectionTestUtils.setField(waitingBooking, "id", id); + ReflectionTestUtils.setField(waitingBooking, "expiredAt", expiredAt); + + return waitingBooking; + } + + public static WaitingBooking getActiveWaitingBooking( + User user, + LocalDateTime expiredAt, + int seatCount, + List seatIds + ) { + WaitingBooking waitingBooking = WaitingBooking.of(user, seatCount, seatIds); + waitingBooking.toActive(seatIds); + ReflectionTestUtils.setField(waitingBooking, "expiredAt", expiredAt); + + return waitingBooking; + } } diff --git a/scheduler/build.gradle b/scheduler/build.gradle index b6318c31..83b1c5cf 100644 --- a/scheduler/build.gradle +++ b/scheduler/build.gradle @@ -1,4 +1,8 @@ dependencies { + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework:spring-tx:6.1.1' + implementation project(':core') testImplementation(testFixtures(project(':core'))) } diff --git a/scheduler/src/main/java/dev/hooon/common/config/MailConfig.java b/scheduler/src/main/java/dev/hooon/common/config/MailConfig.java new file mode 100644 index 00000000..2606c4fc --- /dev/null +++ b/scheduler/src/main/java/dev/hooon/common/config/MailConfig.java @@ -0,0 +1,15 @@ +package dev.hooon.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class MailConfig { + + @Bean + public JavaMailSender javaMailSender() { + return new JavaMailSenderImpl(); + } +} diff --git a/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java b/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java index 47acbae0..06e8e389 100644 --- a/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java +++ b/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java @@ -1,23 +1,23 @@ package dev.hooon.common.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import org.springframework.scheduling.config.ScheduledTaskRegistrar; @Configuration @EnableScheduling -public class SchedulerConfig implements SchedulingConfigurer { +@EnableAsync +public class SchedulerConfig { - @Override - public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - - scheduler.setPoolSize(10); - scheduler.setThreadNamePrefix("scheduler-thread-"); - scheduler.initialize(); - - taskRegistrar.setScheduler(scheduler); + @Bean("scheduler") + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler(); + executor.setPoolSize(5); + executor.setThreadNamePrefix("scheduler-thread-"); + executor.initialize(); + return executor; } } diff --git a/scheduler/src/main/java/dev/hooon/mail/MailSender.java b/scheduler/src/main/java/dev/hooon/mail/MailSender.java new file mode 100644 index 00000000..a4b74579 --- /dev/null +++ b/scheduler/src/main/java/dev/hooon/mail/MailSender.java @@ -0,0 +1,57 @@ +package dev.hooon.mail; + +import java.io.UnsupportedEncodingException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.ITemplateEngine; +import org.thymeleaf.context.Context; + +import dev.hooon.mail.dto.WaitingBookingMailDto; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; + +@Component +public class MailSender { + + private static final String PERSONAL = "Hooonterpark"; + private static final String TITLE = "[훈터파크] 예매대기 활성 알림"; + private static final String TEMPLATE = "waitingBookingNotification"; + + private final String from; + private final JavaMailSender mailSender; + private final ITemplateEngine templateEngine; + + public MailSender( + @Value("${spring.mail.username}") + String from, + JavaMailSender mailSender, + ITemplateEngine templateEngine + ) { + this.from = from; + this.mailSender = mailSender; + this.templateEngine = templateEngine; + } + + public void sendWaitingBookingNotificationMail(WaitingBookingMailDto mailDto) + throws MessagingException, UnsupportedEncodingException + { + Context context = new Context(); + context.setVariable("nickname", mailDto.nickname()); + context.setVariable("showName", mailDto.showName()); + + String htmlTemplate = templateEngine.process(TEMPLATE, context); + + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, "UTF-8"); + mimeMessageHelper.setTo(mailDto.email()); + mimeMessageHelper.setFrom(new InternetAddress(from, PERSONAL)); + mimeMessageHelper.setSubject(TITLE); + mimeMessageHelper.setText(htmlTemplate, true); + + mailSender.send(mimeMessage); + } +} diff --git a/scheduler/src/main/java/dev/hooon/mail/dto/WaitingBookingMailDto.java b/scheduler/src/main/java/dev/hooon/mail/dto/WaitingBookingMailDto.java new file mode 100644 index 00000000..163f21a5 --- /dev/null +++ b/scheduler/src/main/java/dev/hooon/mail/dto/WaitingBookingMailDto.java @@ -0,0 +1,8 @@ +package dev.hooon.mail.dto; + +public record WaitingBookingMailDto( + String nickname, + String email, + String showName +) { +} diff --git a/scheduler/src/main/java/dev/hooon/mail/event/WaitingBookingMailEventListener.java b/scheduler/src/main/java/dev/hooon/mail/event/WaitingBookingMailEventListener.java new file mode 100644 index 00000000..37b52de4 --- /dev/null +++ b/scheduler/src/main/java/dev/hooon/mail/event/WaitingBookingMailEventListener.java @@ -0,0 +1,40 @@ +package dev.hooon.mail.event; + +import java.io.UnsupportedEncodingException; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import dev.hooon.common.exception.NotFoundException; +import dev.hooon.mail.MailSender; +import dev.hooon.mail.dto.WaitingBookingMailDto; +import dev.hooon.show.domain.repository.SeatRepository; +import dev.hooon.show.exception.ShowErrorCode; +import dev.hooon.waitingbooking.event.WaitingBookingActiveEvent; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WaitingBookingMailEventListener { + + private final MailSender mailSender; + private final SeatRepository seatRepository; + + @Async("scheduler") + @EventListener + public void sendWaitingBookingMail(WaitingBookingActiveEvent event) + throws MessagingException, UnsupportedEncodingException + { + String showName = seatRepository.findShowNameById(event.seatId()) + .orElseThrow(() -> new NotFoundException(ShowErrorCode.SHOW_NAME_NOT_FOUND)); + + WaitingBookingMailDto mailDto = new WaitingBookingMailDto( + event.nickname(), + event.email(), + showName + ); + mailSender.sendWaitingBookingNotificationMail(mailDto); + } +} diff --git a/scheduler/src/main/java/dev/hooon/waitingbooking/scheduler/WaitingBookingScheduler.java b/scheduler/src/main/java/dev/hooon/waitingbooking/scheduler/WaitingBookingScheduler.java index 8ca0ede2..9095edca 100644 --- a/scheduler/src/main/java/dev/hooon/waitingbooking/scheduler/WaitingBookingScheduler.java +++ b/scheduler/src/main/java/dev/hooon/waitingbooking/scheduler/WaitingBookingScheduler.java @@ -12,8 +12,13 @@ public class WaitingBookingScheduler { private final WaitingBookingFacade waitingBookingFacade; - @Scheduled(cron = "0 */10 * * * *") + @Scheduled(cron = "0 0/10 * * * *") public void scheduleWaitingBookingProcess() { waitingBookingFacade.processWaitingBooking(); } + + @Scheduled(cron = "0/5 * * * * *") + public void scheduleExpiredWaitingBookingProcess() { + waitingBookingFacade.processExpiredWaitingBooking(); + } } diff --git a/scheduler/src/main/resources/templates/waitingBookingNotification.html b/scheduler/src/main/resources/templates/waitingBookingNotification.html new file mode 100644 index 00000000..c99e8863 --- /dev/null +++ b/scheduler/src/main/resources/templates/waitingBookingNotification.html @@ -0,0 +1,14 @@ + + + + + hooonterpark + + +
+

인터파크 예매대기 알림

+

+

6시간 안에 예매하지 않을 시 해당 좌석에 대한 예매 우선권이 사라집니다

+
+ + \ No newline at end of file diff --git a/scheduler/src/test/java/dev/hooon/mail/MailSenderTest.java b/scheduler/src/test/java/dev/hooon/mail/MailSenderTest.java new file mode 100644 index 00000000..452d8264 --- /dev/null +++ b/scheduler/src/test/java/dev/hooon/mail/MailSenderTest.java @@ -0,0 +1,48 @@ +package dev.hooon.mail; + +import static org.mockito.BDDMockito.*; + +import java.io.UnsupportedEncodingException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mail.javamail.JavaMailSender; +import org.thymeleaf.ITemplateEngine; +import org.thymeleaf.context.Context; + +import dev.hooon.mail.dto.WaitingBookingMailDto; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +@DisplayName("[MailSender 테스트]") +class MailSenderTest { + + private final MailSender mailSender; + private final JavaMailSender javaMailSender = Mockito.mock(JavaMailSender.class); + private final ITemplateEngine iTemplateEngine = Mockito.mock(ITemplateEngine.class); + + public MailSenderTest() { + this.mailSender = new MailSender("hello123@naver.com", javaMailSender, iTemplateEngine); + } + + @Test + @DisplayName("[예약대기 활성화 알림 메일을 전송한다]") + void sendWaitingBookingNotificationMail_test() throws MessagingException, UnsupportedEncodingException { + //given + given(iTemplateEngine.process(eq("waitingBookingNotification"), any(Context.class))) + .willReturn("httpTemplate"); + + MimeMessage mockMimeMessage = Mockito.mock(MimeMessage.class); + given(javaMailSender.createMimeMessage()).willReturn(mockMimeMessage); + + WaitingBookingMailDto waitingBookingMailDto = new WaitingBookingMailDto("nickname", "hello123@naver.com", + "showName"); + + //when + mailSender.sendWaitingBookingNotificationMail(waitingBookingMailDto); + + //then + verify(javaMailSender, times(1)).send(mockMimeMessage); + } +} \ No newline at end of file diff --git a/scheduler/src/test/java/dev/hooon/mail/event/WaitingBookingMailEventListenerTest.java b/scheduler/src/test/java/dev/hooon/mail/event/WaitingBookingMailEventListenerTest.java new file mode 100644 index 00000000..2e0aa2b4 --- /dev/null +++ b/scheduler/src/test/java/dev/hooon/mail/event/WaitingBookingMailEventListenerTest.java @@ -0,0 +1,71 @@ +package dev.hooon.mail.event; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.io.UnsupportedEncodingException; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import dev.hooon.common.exception.NotFoundException; +import dev.hooon.mail.MailSender; +import dev.hooon.mail.dto.WaitingBookingMailDto; +import dev.hooon.show.domain.repository.SeatRepository; +import dev.hooon.show.exception.ShowErrorCode; +import dev.hooon.waitingbooking.event.WaitingBookingActiveEvent; +import jakarta.mail.MessagingException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[WaitingBookingMailEventListener 테스트]") +class WaitingBookingMailEventListenerTest { + + @InjectMocks + private WaitingBookingMailEventListener listener; + @Mock + private MailSender mailSender; + @Mock + private SeatRepository seatRepository; + + @Test + @DisplayName("[예약대기 활성화 이벤트를 수신하여 예약대기 알림 이메일을 발송한다]") + void sendWaitingBookingMail_test1() throws MessagingException, UnsupportedEncodingException { + //given + WaitingBookingActiveEvent event = new WaitingBookingActiveEvent("nickname", "hello123@naver.com", 1L); + + String showName = "showName"; + given(seatRepository.findShowNameById(event.seatId())) + .willReturn(Optional.of(showName)); + + //when + listener.sendWaitingBookingMail(event); + + //then + WaitingBookingMailDto waitingBookingMailDto = new WaitingBookingMailDto( + event.nickname(), + event.email(), + showName + ); + verify(mailSender, times(1)).sendWaitingBookingNotificationMail(waitingBookingMailDto); + } + + @Test + @DisplayName("[좌석 id 에 해당하는 공연이름이 존재하지 않아 실패한다]") + void sendWaitingBookingMail_test2() { + //given + WaitingBookingActiveEvent event = new WaitingBookingActiveEvent("nickname", "hello123@naver.com", 1L); + + given(seatRepository.findShowNameById(event.seatId())) + .willReturn(Optional.empty()); + + //when, then + assertThatThrownBy(() -> listener.sendWaitingBookingMail(event)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(ShowErrorCode.SHOW_NAME_NOT_FOUND.getMessage()); + } +} \ No newline at end of file diff --git a/scheduler/src/test/java/dev/hooon/waitingbooking/scheduler/WaitingBookingSchedulerTest.java b/scheduler/src/test/java/dev/hooon/waitingbooking/scheduler/WaitingBookingSchedulerTest.java index 29d45236..fbd43eb8 100644 --- a/scheduler/src/test/java/dev/hooon/waitingbooking/scheduler/WaitingBookingSchedulerTest.java +++ b/scheduler/src/test/java/dev/hooon/waitingbooking/scheduler/WaitingBookingSchedulerTest.java @@ -1,16 +1,26 @@ package dev.hooon.waitingbooking.scheduler; import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import java.io.UnsupportedEncodingException; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureTestEntityManager; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.transaction.annotation.Transactional; import dev.hooon.common.fixture.SeatFixture; +import dev.hooon.common.fixture.WaitingBookingFixture; import dev.hooon.common.support.TestContainerSupport; +import dev.hooon.mail.event.WaitingBookingMailEventListener; import dev.hooon.show.domain.entity.seat.Seat; import dev.hooon.show.domain.entity.seat.SeatStatus; import dev.hooon.show.domain.repository.SeatRepository; @@ -20,9 +30,14 @@ import dev.hooon.waitingbooking.domain.entity.WaitingBooking; import dev.hooon.waitingbooking.domain.entity.WaitingStatus; import dev.hooon.waitingbooking.domain.repository.WaitingBookingRepository; +import dev.hooon.waitingbooking.event.WaitingBookingActiveEvent; +import jakarta.mail.MessagingException; @DisplayName("[WaitingBookingScheduler 테스트]") @SpringBootTest +@AutoConfigureTestEntityManager +@Transactional +@RecordApplicationEvents class WaitingBookingSchedulerTest extends TestContainerSupport { @Autowired @@ -33,6 +48,10 @@ class WaitingBookingSchedulerTest extends TestContainerSupport { private UserRepository userRepository; @Autowired private SeatRepository seatRepository; + @Autowired + private TestEntityManager entityManager; + @MockBean + private WaitingBookingMailEventListener eventListener; private void assertSeatStatus(Long id, SeatStatus expected) { assertThat(seatRepository.findById(id)).isPresent() @@ -50,7 +69,7 @@ private void assertWaitingBookingStatus(Long id, WaitingStatus expected) { @Test @DisplayName("[현재 대기중인 예약대기를 처리한다]") - void scheduleWaitingBookingProcess() { + void scheduleWaitingBookingProcess() throws MessagingException, UnsupportedEncodingException { //given List seats = List.of( SeatFixture.getSeat(SeatStatus.CANCELED), @@ -83,6 +102,8 @@ void scheduleWaitingBookingProcess() { //when waitingBookingScheduler.scheduleWaitingBookingProcess(); + entityManager.flush(); + entityManager.clear(); //then assertSeatStatus(seats.get(0).getId(), SeatStatus.WAITING); @@ -92,5 +113,53 @@ void scheduleWaitingBookingProcess() { assertWaitingBookingStatus(waitingBookings.get(0).getId(), WaitingStatus.ACTIVATION); assertWaitingBookingStatus(waitingBookings.get(1).getId(), WaitingStatus.ACTIVATION); assertWaitingBookingStatus(waitingBookings.get(2).getId(), WaitingStatus.WAITING); + + verify(eventListener, times(2)).sendWaitingBookingMail(any(WaitingBookingActiveEvent.class)); + } + + @Test + @DisplayName("[만료된 활성화 상태인 예약대기를 처리한다]") + void scheduleExpiredWaitingBookingProcess_test() { + //given + List seats = List.of( + SeatFixture.getSeat(SeatStatus.WAITING), + SeatFixture.getSeat(SeatStatus.WAITING), + SeatFixture.getSeat(SeatStatus.WAITING) + ); + seatRepository.saveAll(seats); + + User user = new User("hello123@naver.com", "name", UserRole.BUYER); + userRepository.save(user); + + LocalDateTime beforeNow = LocalDateTime.now().minusSeconds(10); + LocalDateTime afterNow = LocalDateTime.now().plusSeconds(10); + + List waitingBookings = List.of( + WaitingBookingFixture.getActiveWaitingBooking( + user, beforeNow, 1, List.of(seats.get(0).getId()) + ), + WaitingBookingFixture.getActiveWaitingBooking( + user, beforeNow, 1, List.of(seats.get(1).getId()) + ), + WaitingBookingFixture.getActiveWaitingBooking( + user, afterNow, 1, List.of(seats.get(2).getId()) + ) + ); + + waitingBookings.forEach(waitingBooking -> waitingBookingRepository.save(waitingBooking)); + + //when + waitingBookingScheduler.scheduleExpiredWaitingBookingProcess(); + entityManager.flush(); + entityManager.clear(); + + //then + assertSeatStatus(seats.get(0).getId(), SeatStatus.AVAILABLE); + assertSeatStatus(seats.get(1).getId(), SeatStatus.AVAILABLE); + assertSeatStatus(seats.get(2).getId(), SeatStatus.WAITING); + + assertWaitingBookingStatus(waitingBookings.get(0).getId(), WaitingStatus.EXPIRED); + assertWaitingBookingStatus(waitingBookings.get(1).getId(), WaitingStatus.EXPIRED); + assertWaitingBookingStatus(waitingBookings.get(2).getId(), WaitingStatus.ACTIVATION); } } \ No newline at end of file diff --git a/scheduler/src/main/resources/application.yml b/scheduler/src/test/resources/application.yml similarity index 51% rename from scheduler/src/main/resources/application.yml rename to scheduler/src/test/resources/application.yml index 990772f0..5cf3cbd3 100644 --- a/scheduler/src/main/resources/application.yml +++ b/scheduler/src/test/resources/application.yml @@ -8,6 +8,19 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + mail: + host: smtp.gmail.com + port: 587 + username: username + password: password + protocol: smtp + properties: + mail: + smtp: + starttls: + enable: true + auth: true + logging: level: org.hibernate.sql: info \ No newline at end of file