Skip to content

Commit

Permalink
feat : 만료 예매대기 처리 스케줄러 구현 (#44)
Browse files Browse the repository at this point in the history
* 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 : 예약대기 활성화 알림 메일발송 이벤트 적용
  • Loading branch information
EunChanNam authored Jan 2, 2024
1 parent ebfb6a4 commit 32a694f
Show file tree
Hide file tree
Showing 35 changed files with 776 additions and 71 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ build/

.DS_Store
core/src/main/resources/application.yml
scheduler/src/main/resources/application.yml

### STS ###
.apt_generated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public interface SeatRepository {

void updateStatusByIdIn(Collection<Long> ids, SeatStatus status);

Optional<String> findShowNameById(Long id);

List<SeatsInfoDto> findSeatInfoByShowIdAndDateAndRound(
Long showId,
LocalDate date,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ public void updateStatusByIdIn(Collection<Long> ids, SeatStatus status) {
}

@Override
public Optional<String> findShowNameById(Long id) {
return seatJpaRepository.findShowNameById(id);
}

public List<SeatsInfoDto> findSeatInfoByShowIdAndDateAndRound(
Long showId, LocalDate date, int round
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,9 @@ public interface SeatJpaRepository extends JpaRepository<Seat, Long> {
@Query("update Seat s SET s.seatStatus = :status where s.id in :ids")
void updateStatusByIdIn(@Param("ids") Collection<Long> 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<String> findShowNameById(@Param("id") Long id);

@Query("""
select
new dev.hooon.show.dto.query.seats.SeatsInfoDto(seat.seatGrade, COUNT(seat), seat.price)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package dev.hooon.waitingbooking.application;

import java.util.Collection;
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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;
Expand All @@ -24,14 +26,27 @@ public WaitingBooking createWaitingBooking(User user, WaitingRegisterRequest req
return waitingBooking;
}

// 대기 상태인 WaitingBooking 조회
// 대기 상태인 WaitingBooking 조회(fetch join selectedSeat + user)
public List<WaitingBooking> 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<Long> confirmedSeatIds) {
waitingBookingRepository.findById(waitingBookingId)
.ifPresent(waitingBooking -> waitingBooking.toActive(confirmedSeatIds));
}

// 활성 상태인 WaitingBooking 조회(fetch join confirmedSeat)
@Transactional(readOnly = true)
public List<WaitingBooking> getWaitingBookingsByStatusIsActivation() {
return waitingBookingRepository.findWithConfirmedSeatsByStatus(WaitingStatus.ACTIVATION);
}

// 활성 상태인 WaitingBooking 을 만료상태로 바꿈
@Transactional
public void expireActiveWaitingBooking(Collection<Long> targetIds) {
waitingBookingRepository.updateStatusByIdIn(WaitingStatus.EXPIRED, targetIds);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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<Long> fetchMatchingSeatIds(
Expand All @@ -41,43 +45,64 @@ private List<Long> fetchMatchingSeatIds(
return matchSeatIds;
}

// 예약대기 등록
public WaitingRegisterResponse registerWaitingBooking(Long userId, WaitingRegisterRequest request) {
User user = userService.getUserById(userId);
WaitingBooking waitingBooking = waitingBookingService.createWaitingBooking(user, request);

return new WaitingRegisterResponse(waitingBooking.getId());
}

// 예약대기 처리
public void processWaitingBooking() {
// 1. 취소된 좌석을 모두 조회한다 (PK Set 으로)
Set<Long> canceledSeatIds = seatService.getCanceledSeatIds();
// 2. 대기중 상태인 예약대기 목록을 날짜순으로 조회한다
List<WaitingBooking> waitingList = waitingBookingService.getWaitingBookingsByStatusIsWaiting();
// 3. waitingList 반복하면서 아래 작업을 수행
/*
* (1) 대기목록에 포함된 좌석의 PK 가 취소된 좌석 Set 에 존재하는지 확인
* (2) 대기목록의 좌석중에서 취소목록에 포함되는 좌석 아이디 가져옴
* (3) 가져온 좌석 아이디 사이즈가 선택좌석개수와 같으면 취소좌석 SET 에서 해당 PK 지우고 좌석 상태를 취소에서 대기로 바꾸고 예약대기를 활성화
* + 사용자에게 메일 발송 (구현 예정)
*/
waitingList.forEach(waitingBooking -> {
// (1)
// (1) 대기목록에 포함된 좌석의 PK 가 취소된 좌석 Set 에 존재하는지 확인
List<Long> selectedSeatIds = waitingBooking.getSelectedSeatIds();
// (2)
// (2) 대기목록의 좌석중에서 취소목록에 포함되는 좌석 아이디 가져옴
List<Long> 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<WaitingBooking> waitingBookings = waitingBookingService.getWaitingBookingsByStatusIsActivation();

List<Long> expiredWaitingBookingIds = new ArrayList<>();
List<Long> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,7 +58,10 @@ public class WaitingBooking extends TimeBaseEntity {
private LocalDateTime expiredAt;

@OneToMany(mappedBy = "waitingBooking", cascade = {REMOVE, PERSIST})
List<WaitingBookingSeat> waitingBookingSeats = new ArrayList<>();
List<SelectedSeat> selectedSeats = new ArrayList<>();

@OneToMany(mappedBy = "waitingBooking", cascade = {REMOVE, PERSIST})
List<ConfirmedSeat> confirmedSeats = new ArrayList<>();

// 생성 메소드
private WaitingBooking(
Expand All @@ -71,13 +76,13 @@ private WaitingBooking(
this.status = WaitingStatus.WAITING;
this.seatCount = seatCount;
this.user = user;
applyWaitingBookingSeats(seatIds);
applySelectedSeats(seatIds);
}

private void applyWaitingBookingSeats(List<Long> seatIds) {
private void applySelectedSeats(List<Long> seatIds) {
seatIds.forEach(seatId -> {
WaitingBookingSeat waitingBookingSeat = WaitingBookingSeat.of(seatId, this);
this.waitingBookingSeats.add(waitingBookingSeat);
SelectedSeat selectedSeat = SelectedSeat.of(seatId, this);
this.selectedSeats.add(selectedSeat);
});
}

Expand All @@ -103,6 +108,12 @@ private void validateSelectedSeats(int seatCount, List<Long> seatIds) {
}
}

private void validateConfirmedSeats(List<Long> seatIds) {
if (seatIds.size() != seatCount) {
throw new ValidationException(INVALID_CONFIRMED_SEAT_COUNT);
}
}

// 팩토리 메소드
public static WaitingBooking of(
User user,
Expand All @@ -112,9 +123,29 @@ public static WaitingBooking of(
return new WaitingBooking(user, seatCount, seatIds);
}

// 대기 등록시 선택한 좌석의 ID 조회
public List<Long> getSelectedSeatIds() {
return waitingBookingSeats.stream()
.map(WaitingBookingSeat::getSeatId)
return selectedSeats.stream()
.map(SelectedSeat::getSeatId)
.toList();
}

// 확정된 좌석의 ID 조회
public List<Long> getConfirmedSeatIds() {
return confirmedSeats.stream()
.map(ConfirmedSeat::getSeatId)
.toList();
}

// activation 상태로 전환 + 만료시간 6시간 뒤로 설정 + ConfirmedSeat 엔티티 추가
public void toActive(List<Long> seatIds) {
this.status = WaitingStatus.ACTIVATION;
this.expiredAt = LocalDateTime.now().plusHours(6);
addConfirmedSeats(seatIds);
}

private void addConfirmedSeats(List<Long> seatIds) {
validateConfirmedSeats(seatIds);
seatIds.forEach(seatId -> this.confirmedSeats.add(ConfirmedSeat.of(seatId, this)));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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.*;
Expand All @@ -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;

Expand All @@ -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);
}
}

Loading

0 comments on commit 32a694f

Please sign in to comment.