diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 08a79e2..02e9343 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.request.ReservationCreateRequest; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.service.ReservationService; @@ -9,13 +10,7 @@ import org.example.siljeun.global.security.PrincipalDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -36,7 +31,7 @@ public ResponseEntity> updatePrice( @DeleteMapping("/{reservationId}") public ResponseEntity> delete( - @AuthenticationPrincipal PrincipalDetails userDetails, @PathVariable Long reservationId) { + @AuthenticationPrincipal PrincipalDetails userDetails, @PathVariable Long reservationId) { String username = userDetails.getUsername(); reservationService.delete(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 취소 완료", null)); @@ -49,4 +44,13 @@ public ResponseEntity> findById( ReservationInfoResponse dto = reservationService.findById(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 조회 성공", dto)); } + + @PostMapping() + public ResponseEntity> createReservation( + @RequestBody @Valid ReservationCreateRequest reservationCreateRequest, + @AuthenticationPrincipal PrincipalDetails userDetails + ){ + reservationService.createReservation(reservationCreateRequest, userDetails.getUserId()); + return ResponseEntity.ok(ResponseDto.success("결제 진행하기", null)); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/ReservationCreateRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/ReservationCreateRequest.java new file mode 100644 index 0000000..177d799 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/ReservationCreateRequest.java @@ -0,0 +1,9 @@ +package org.example.siljeun.domain.reservation.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ReservationCreateRequest( + @NotNull Long scheduleId +) +{ +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index f2032a2..dc9acb2 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -19,7 +19,7 @@ import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.enums.Discount; import org.example.siljeun.domain.reservation.enums.TicketReceipt; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.annotation.CreatedDate; diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 5b1767d..23c38b2 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -1,20 +1,27 @@ package org.example.siljeun.domain.reservation.service; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.reservation.dto.request.ReservationCreateRequest; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.entity.Reservation; import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.reservation.repository.ReservationRepository; -import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; + +@Slf4j @Service @RequiredArgsConstructor public class ReservationService { @@ -23,6 +30,8 @@ public class ReservationService { private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final RedisTemplate redisTemplate; + @Transactional public void save(Long userId, Long seatScheduleInfoId) { @@ -78,4 +87,45 @@ public ReservationInfoResponse findById(String username, Long reservationId) { return ReservationInfoResponse.from(reservation); } + + @Transactional + public void createReservation(ReservationCreateRequest reservationCreateRequest, Long userId){ + + //유저 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다.")); + + Long scheduleId = reservationCreateRequest.scheduleId(); + //유저가 해당 회차에 선택한 좌석 검증 + String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; + log.info("예매 정보 생성 시도 user : " + userId + "scheduleId : " + scheduleId + " key) " + redisSelectedKey ); + String selectedId = redisTemplate.opsForValue().get(redisSelectedKey); + + if (selectedId == null) { + throw new IllegalStateException("선택한 좌석이 없습니다."); + } + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(Long.valueOf(selectedId)) + .orElseThrow(() -> new EntityNotFoundException("좌석 정보를 찾을 수 없습니다.")); + + //해당 좌석의 상태 검증 + String redisStatusHashKey = "seatStatus:" + scheduleId; + Object redisStatusObj = redisTemplate.opsForHash().get(redisStatusHashKey, selectedId); + + if (redisStatusObj == null || !redisStatusObj.toString().equals(SeatStatus.SELECTED.name())) { + throw new IllegalStateException("좌석 상태가 유효하지 않습니다. 다시 선택해주세요."); + } + + //예매 정보 생성 + Reservation reservation = new Reservation(user, seatScheduleInfo); + reservationRepository.save(reservation); + + //좌석 상태 결제 진행 중으로 변경 + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.HOLD); + seatScheduleInfoRepository.save(seatScheduleInfo); + redisTemplate.opsForHash().put(redisStatusHashKey, selectedId, SeatStatus.HOLD.name()); + + //유저가 선점한 좌석 정보 - 결제 진행 상태일 때의 만료 시간 1시간 + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(60)); + } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java deleted file mode 100644 index 71b1296..0000000 --- a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.example.siljeun.domain.schedule.controller; - -import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.schedule.service.SeatScheduleInfoService; -import org.example.siljeun.global.security.PrincipalDetails; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; - -import java.util.Map; - -@Controller -@RequiredArgsConstructor -public class SeatScheduleInfoController { - - private final SeatScheduleInfoService seatScheduleInfoService; - - @PostMapping("/seat-schedule-infos/{seatScheduleInfoId}") - public ResponseEntity selectSeat( - @PathVariable Long seatScheduleInfoId, - @AuthenticationPrincipal PrincipalDetails userDetails - ) { - seatScheduleInfoService.selectSeat(userDetails.getUserId(), userDetails.getUsername(), - seatScheduleInfoId); - return ResponseEntity.ok("좌석이 선택되었습니다."); - } - - @GetMapping("/schedules/{scheduleId}/seat-schedule-infos") - public ResponseEntity> getSeatScheduleInfos( - @PathVariable Long scheduleId - ) { - return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); - } -} diff --git a/src/main/java/org/example/siljeun/domain/schedule/entity/Schedule.java b/src/main/java/org/example/siljeun/domain/schedule/entity/Schedule.java index d5b7129..d294cf0 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/entity/Schedule.java +++ b/src/main/java/org/example/siljeun/domain/schedule/entity/Schedule.java @@ -1,18 +1,15 @@ package org.example.siljeun.domain.schedule.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; + import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + import lombok.Getter; import lombok.NoArgsConstructor; import org.example.siljeun.domain.concert.entity.Concert; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.global.entity.BaseEntity; @Entity diff --git a/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java b/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java deleted file mode 100644 index 4aba080..0000000 --- a/src/main/java/org/example/siljeun/domain/schedule/scheduler/TicketingRedisScheduler.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.example.siljeun.domain.schedule.scheduler; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.example.siljeun.domain.schedule.entity.Schedule; -import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j -@Component -public class TicketingRedisScheduler { - private final ScheduleRepository scheduleRepository; - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final RedisTemplate redisStatusTemplate; - - public TicketingRedisScheduler( - ScheduleRepository scheduleRepository, - SeatScheduleInfoRepository seatScheduleInfoRepository, - @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate - ){ - this.scheduleRepository = scheduleRepository; - this.seatScheduleInfoRepository = seatScheduleInfoRepository; - this.redisStatusTemplate = redisStatusTemplate; - } - - @Scheduled(fixedRate = 60_000) - public void loadSeatStatusToRedis() { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime fiveMinutesLater = now.plusMinutes(5); //티켓팅 시작 시간이 임박한 회차에 대해 미리 Redis에 정보 적재 - - List openedSchedules = scheduleRepository.findAllByTicketingStartTimeBetween(now, fiveMinutesLater); - - for (Schedule schedule : openedSchedules) { - List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - - for(SeatScheduleInfo seatScheduleInfo : seatScheduleInfos){ - String key = "seatStatus:" + seatScheduleInfo.getId().toString(); - String value = seatScheduleInfo.getStatus().name(); - redisStatusTemplate.opsForValue().set(key, value); - } - } - } -} diff --git a/src/main/java/org/example/siljeun/domain/schedule/service/ScheduleServiceImpl.java b/src/main/java/org/example/siljeun/domain/schedule/service/ScheduleServiceImpl.java index b421d43..d205557 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/service/ScheduleServiceImpl.java +++ b/src/main/java/org/example/siljeun/domain/schedule/service/ScheduleServiceImpl.java @@ -1,8 +1,10 @@ package org.example.siljeun.domain.schedule.service; import jakarta.persistence.EntityNotFoundException; + import java.util.List; import java.util.NoSuchElementException; + import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.repository.ConcertRepository; @@ -11,11 +13,11 @@ import org.example.siljeun.domain.schedule.dto.response.ScheduleSimpleResponse; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.Seat; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; -import org.example.siljeun.domain.venue.repository.VenueSeatRepository; +import org.example.siljeun.domain.seat.repository.SeatRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,7 +28,7 @@ public class ScheduleServiceImpl implements ScheduleService { private final ScheduleRepository scheduleRepository; private final ConcertRepository concertRepository; - private final VenueSeatRepository venueSeatRepository; + private final SeatRepository seatRepository; private final SeatScheduleInfoRepository seatScheduleInfoRepository; @Override @@ -44,7 +46,7 @@ public ScheduleSimpleResponse createSchedule(ScheduleCreateRequest request) { Schedule saved = scheduleRepository.save(schedule); //회차 생성 시, 회차별 좌석 정보도 함께 생성 - List seats = venueSeatRepository.findByVenue(concert.getVenue()); + List seats = seatRepository.findByVenue(concert.getVenue()); List seatInfos = seats.stream() .map(seat -> SeatScheduleInfo.from( @@ -73,22 +75,23 @@ public ScheduleSimpleResponse updateSchedule(Long id, ScheduleUpdateRequest requ schedule.getTicketingStartTime()); } - @Override - @Transactional - public void deleteSchedule(Long id) { - if (!scheduleRepository.existsById(id)) { - throw new EntityNotFoundException("해당 회차가 존재하지 않습니다."); + @Override + @Transactional + public void deleteSchedule(Long id) { + Schedule schedule = scheduleRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + + seatScheduleInfoRepository.deleteBySchedule(schedule); + scheduleRepository.deleteById(id); + } + + @Override + @Transactional(readOnly = true) + public List getSchedulesByConcertId(Long concertId) { + List schedules = scheduleRepository.findByConcertIdWithConcert(concertId); + return schedules.stream() + .map( + s -> new ScheduleSimpleResponse(s.getId(), s.getStartTime(), s.getTicketingStartTime())) + .toList(); } - scheduleRepository.deleteById(id); - } - - @Override - @Transactional(readOnly = true) - public List getSchedulesByConcertId(Long concertId) { - List schedules = scheduleRepository.findByConcertIdWithConcert(concertId); - return schedules.stream() - .map( - s -> new ScheduleSimpleResponse(s.getId(), s.getStartTime(), s.getTicketingStartTime())) - .toList(); - } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java deleted file mode 100644 index 9f83f05..0000000 --- a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.example.siljeun.domain.schedule.service; - -import jakarta.persistence.EntityNotFoundException; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.example.siljeun.domain.reservation.service.WaitingQueueService; -import org.example.siljeun.domain.schedule.entity.Schedule; -import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; -import org.example.siljeun.domain.seat.enums.SeatStatus; -import org.example.siljeun.global.lock.DistributedLock; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -@Slf4j -@Service -public class SeatScheduleInfoService { - - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final ScheduleRepository scheduleRepository; - private final RedisTemplate redisSeatUserTemplate; - private final RedisTemplate redisStatusTemplate; - private final WaitingQueueService waitingQueueService; - - public SeatScheduleInfoService( - SeatScheduleInfoRepository seatScheduleInfoRepository, - ScheduleRepository scheduleRepository, - @Qualifier("redisLongTemplate") RedisTemplate redisSeatUserTemplate, - @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate, - WaitingQueueService waitingQueueService) { - this.seatScheduleInfoRepository = seatScheduleInfoRepository; - this.scheduleRepository = scheduleRepository; - this.redisSeatUserTemplate = redisSeatUserTemplate; - this.redisStatusTemplate = redisStatusTemplate; - this.waitingQueueService = waitingQueueService; - } - - @Transactional - @DistributedLock(key = "'seat:' + #seatScheduleInfoId") - public void selectSeat(Long userId, String username, Long seatScheduleInfoId) { - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); - - boolean hasPassedQueue = waitingQueueService.hasPassedWaitingQueue( - seatScheduleInfo.getSchedule().getId(), username); - if (!hasPassedQueue) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "정상적인 접근이 아닙니다."); - } - - if (!seatScheduleInfo.isAvailable()) { - //log.info("이미 선점된 좌석입니다."); - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); - } - - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); - - String redisKey = "seat:" + seatScheduleInfoId; - redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); - - String redisStatusKey = "seatStatus:" + seatScheduleInfoId; - redisStatusTemplate.opsForValue() - .set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); - - waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(), username); - } - - public Map getSeatStatusMap(Long scheduleId) { - - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); - - List seatScheduleInfos = - seatScheduleInfoRepository.findAllBySchedule(schedule); - - Map result = new HashMap<>(); - - for (SeatScheduleInfo info : seatScheduleInfos) { - String redisKey = "seatStatus:" + info.getId(); - String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); - - String status; - if (redisStatus != null) { - status = redisStatus; - } else if (info.getStatus() - == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우 - status = SeatStatus.AVAILABLE.name(); - } else { - status = info.getStatus().name(); - } - - result.put("seatScheduleInfo-" + info.getId().toString(), status); - } - - return result; - } -} diff --git a/src/main/java/org/example/siljeun/domain/venue/controller/VenueSeatController.java b/src/main/java/org/example/siljeun/domain/seat/controller/SeatController.java similarity index 64% rename from src/main/java/org/example/siljeun/domain/venue/controller/VenueSeatController.java rename to src/main/java/org/example/siljeun/domain/seat/controller/SeatController.java index 8805978..8e81078 100644 --- a/src/main/java/org/example/siljeun/domain/venue/controller/VenueSeatController.java +++ b/src/main/java/org/example/siljeun/domain/seat/controller/SeatController.java @@ -1,9 +1,10 @@ -package org.example.siljeun.domain.venue.controller; +package org.example.siljeun.domain.seat.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.seat.dto.request.SeatCreateRequest; -import org.example.siljeun.domain.venue.service.VenueSeatService; +import org.example.siljeun.domain.seat.service.SeatService; +import org.example.siljeun.global.dto.ResponseDto; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; @@ -16,17 +17,17 @@ @Controller @RequiredArgsConstructor @RequestMapping("/venues") -public class VenueSeatController { +public class SeatController { - private final VenueSeatService venueSeatService; + private final SeatService seatService; //좌석 정보를 CSV 파일 또는 GUI로 다수의 정보를 한번에 등록한다. @PostMapping("/{venueId}/seats") - public ResponseEntity createVenueSeats( + public ResponseEntity> createVenueSeats( @PathVariable Long venueId, @RequestBody @Valid List seatCreateRequests ){ - venueSeatService.createSeats(venueId, seatCreateRequests); - return ResponseEntity.ok().build(); + seatService.createSeats(venueId, seatCreateRequests); + return ResponseEntity.ok(ResponseDto.success("좌석 정보가 등록되었습니다.", null)); } } diff --git a/src/main/java/org/example/siljeun/domain/seat/entity/Seat.java b/src/main/java/org/example/siljeun/domain/seat/entity/Seat.java index 47a230c..ac04e55 100644 --- a/src/main/java/org/example/siljeun/domain/seat/entity/Seat.java +++ b/src/main/java/org/example/siljeun/domain/seat/entity/Seat.java @@ -48,4 +48,8 @@ public Seat(Venue venue, String section, String row, String column, String defau public static Seat from(Venue venue, SeatCreateRequest request) { return new Seat(venue, request.section(), request.row(), request.column(), request.defaultGrade(), request.defaultPrice()); } + + public String seatNumber(){ + return section + "구역 " + row + "열 " + column + "번"; + } } diff --git a/src/main/java/org/example/siljeun/domain/venue/repository/VenueSeatRepository.java b/src/main/java/org/example/siljeun/domain/seat/repository/SeatRepository.java similarity index 65% rename from src/main/java/org/example/siljeun/domain/venue/repository/VenueSeatRepository.java rename to src/main/java/org/example/siljeun/domain/seat/repository/SeatRepository.java index 8fbffa1..cf44e06 100644 --- a/src/main/java/org/example/siljeun/domain/venue/repository/VenueSeatRepository.java +++ b/src/main/java/org/example/siljeun/domain/seat/repository/SeatRepository.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.venue.repository; +package org.example.siljeun.domain.seat.repository; import org.example.siljeun.domain.seat.entity.Seat; import org.example.siljeun.domain.venue.entity.Venue; @@ -6,7 +6,7 @@ import java.util.List; -public interface VenueSeatRepository extends JpaRepository { +public interface SeatRepository extends JpaRepository { List findByVenue(Venue venue); } diff --git a/src/main/java/org/example/siljeun/domain/venue/service/VenueSeatService.java b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java similarity index 75% rename from src/main/java/org/example/siljeun/domain/venue/service/VenueSeatService.java rename to src/main/java/org/example/siljeun/domain/seat/service/SeatService.java index bc6fe4b..13e60b3 100644 --- a/src/main/java/org/example/siljeun/domain/venue/service/VenueSeatService.java +++ b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java @@ -1,37 +1,39 @@ -package org.example.siljeun.domain.venue.service; +package org.example.siljeun.domain.seat.service; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.seat.dto.request.SeatCreateRequest; import org.example.siljeun.domain.seat.entity.Seat; import org.example.siljeun.domain.venue.entity.Venue; import org.example.siljeun.domain.venue.repository.VenueRepository; -import org.example.siljeun.domain.venue.repository.VenueSeatRepository; +import org.example.siljeun.domain.seat.repository.SeatRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.util.ArrayList; import java.util.List; @Service @RequiredArgsConstructor -public class VenueSeatService { +public class SeatService { private final VenueRepository venueRepository; - private final VenueSeatRepository venueSeatRepository; + private final SeatRepository seatRepository; @Transactional public void createSeats(Long venueId, List seatCreateRequests){ Venue venue = venueRepository.findById(venueId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 공연장을 찾을 수 없습니다.")); //Throw 예외 설정 필요 + if (seatCreateRequests.size() > venue.getSeatCapacity()) { + throw new IllegalArgumentException("좌석 수가 공연장 수용 인원(capacity)을 초과했습니다."); + } //공연장 ID, 구역, 열, 번호를 바탕으로 고유하도록 설정하였으나 //좌석 정보가 중복되는 경우를 다루지 않아 추후 리팩토링이 필요함 List seats = seatCreateRequests.stream() .map(request -> Seat.from(venue, request)) .toList(); - venueSeatRepository.saveAll(seats); + seatRepository.saveAll(seats); } } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java new file mode 100644 index 0000000..e19e85f --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java @@ -0,0 +1,49 @@ +package org.example.siljeun.domain.seatscheduleinfo.controller; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; +import org.example.siljeun.global.dto.ResponseDto; +import org.example.siljeun.global.security.PrincipalDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/schedules/{scheduleId}") +public class SeatScheduleInfoController { + + private final SeatScheduleInfoService seatScheduleInfoService; + + @PostMapping("/seat-schedule-infos") + public ResponseEntity> forceSeatScheduleInfoInRedis( + @PathVariable Long scheduleId + ) + { + seatScheduleInfoService.forceSeatScheduleInfoInRedis(scheduleId); + return ResponseEntity.ok(ResponseDto.success("Redis 적재 완료 scheduleId : " + scheduleId, null)); + } + + @PostMapping("/seat-schedule-infos/{seatScheduleInfoId}") + public ResponseEntity> selectSeat( + @PathVariable Long scheduleId, + @PathVariable Long seatScheduleInfoId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + seatScheduleInfoService.selectSeat(userDetails.getUserId(), scheduleId, seatScheduleInfoId); + return ResponseEntity.ok(ResponseDto.success( "좌석이 선택되었습니다. seatScheduleInfoId : " + seatScheduleInfoId.toString(), null)); + } + + @GetMapping("/seat-schedule-infos") + public ResponseEntity> getSeatScheduleInfos( + @PathVariable Long scheduleId + ) { + return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); + } +} diff --git a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleInfoCreateRequest.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleInfoCreateRequest.java similarity index 85% rename from src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleInfoCreateRequest.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleInfoCreateRequest.java index 6abf4db..f1014ba 100644 --- a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleInfoCreateRequest.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleInfoCreateRequest.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.seat.dto.request; +package org.example.siljeun.domain.seatscheduleinfo.dto.request; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateInfoRequest.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateInfoRequest.java similarity index 85% rename from src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateInfoRequest.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateInfoRequest.java index 2098038..395843d 100644 --- a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateInfoRequest.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateInfoRequest.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.seat.dto.request; +package org.example.siljeun.domain.seatscheduleinfo.dto.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateStatusRequest.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateStatusRequest.java similarity index 79% rename from src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateStatusRequest.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateStatusRequest.java index 58005be..b3fbf42 100644 --- a/src/main/java/org/example/siljeun/domain/seat/dto/request/SeatScheduleUpdateStatusRequest.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/request/SeatScheduleUpdateStatusRequest.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.seat.dto.request; +package org.example.siljeun.domain.seatscheduleinfo.dto.request; import jakarta.validation.constraints.NotNull; import org.example.siljeun.domain.seat.enums.SeatStatus; diff --git a/src/main/java/org/example/siljeun/domain/seat/dto/response/SeatScheduleInfoResponse.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/response/SeatScheduleInfoResponse.java similarity index 71% rename from src/main/java/org/example/siljeun/domain/seat/dto/response/SeatScheduleInfoResponse.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/response/SeatScheduleInfoResponse.java index 15ecc82..2955274 100644 --- a/src/main/java/org/example/siljeun/domain/seat/dto/response/SeatScheduleInfoResponse.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/dto/response/SeatScheduleInfoResponse.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.seat.dto.response; +package org.example.siljeun.domain.seatscheduleinfo.dto.response; public record SeatScheduleInfoResponse( Long seatScheduleInfoId, diff --git a/src/main/java/org/example/siljeun/domain/seat/entity/SeatScheduleInfo.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/entity/SeatScheduleInfo.java similarity index 94% rename from src/main/java/org/example/siljeun/domain/seat/entity/SeatScheduleInfo.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/entity/SeatScheduleInfo.java index c97daab..0fbfb98 100644 --- a/src/main/java/org/example/siljeun/domain/seat/entity/SeatScheduleInfo.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/entity/SeatScheduleInfo.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.seat.entity; +package org.example.siljeun.domain.seatscheduleinfo.entity; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -12,6 +12,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.seat.entity.Seat; import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.global.entity.BaseEntity; diff --git a/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/repository/SeatScheduleInfoRepository.java similarity index 62% rename from src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java rename to src/main/java/org/example/siljeun/domain/seatscheduleinfo/repository/SeatScheduleInfoRepository.java index 20f047d..e7d0d2e 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/repository/SeatScheduleInfoRepository.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/repository/SeatScheduleInfoRepository.java @@ -1,7 +1,7 @@ -package org.example.siljeun.domain.schedule.repository; +package org.example.siljeun.domain.seatscheduleinfo.repository; import org.example.siljeun.domain.schedule.entity.Schedule; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -9,4 +9,6 @@ public interface SeatScheduleInfoRepository extends JpaRepository { List findAllBySchedule(Schedule schedule); + + void deleteBySchedule(Schedule schedule); } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatStatusPreloaderScheduler.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatStatusPreloaderScheduler.java new file mode 100644 index 0000000..6c05267 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatStatusPreloaderScheduler.java @@ -0,0 +1,47 @@ +package org.example.siljeun.domain.seatscheduleinfo.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SeatStatusPreloaderScheduler { + private final ScheduleRepository scheduleRepository; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final RedisTemplate redisTemplate; + + @Scheduled(fixedRate = 60_000) + public void loadSeatStatusToRedis() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime fiveMinutesLater = now.plusMinutes(5); + + List openedSchedules = scheduleRepository.findAllByTicketingStartTimeBetween(now, fiveMinutesLater); + + for (Schedule schedule : openedSchedules) { + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + + String key = "seatStatus:" + schedule.getId(); + Map seatStatusMap = new HashMap<>(); + + for (SeatScheduleInfo seat : seatScheduleInfos) { + seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); + } + + redisTemplate.opsForHash().putAll(key, seatStatusMap); + log.info("✅ scheduleId: {}의 seatSchedulerInfos 적재 완료", schedule.getId()); + } + } +} diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java new file mode 100644 index 0000000..56cc1d9 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -0,0 +1,114 @@ +package org.example.siljeun.domain.seatscheduleinfo.service; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.seat.entity.Seat; +import org.example.siljeun.domain.seat.repository.SeatRepository; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.global.lock.DistributedLock; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SeatScheduleInfoService { + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisTemplate; + + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") + public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + Schedule schedule = seatScheduleInfo.getSchedule(); + + if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ + log.info("예매 미오픈."); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); + } + + if (!seatScheduleInfo.isAvailable()) { + log.info("이미 선점된 좌석입니다."); + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); + } + + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); + + //userId와 schedule Id가 key이고 seatSchduleInfoId로 구성된 value인 형태로 저장 + String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; + + if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); + } + + redisTemplate.opsForValue().set(redisSelectedKey, seatScheduleInfoId.toString()); + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); + + //seatScheduleInfoId의 seatStatus 상태 변경 + String redisHashKey = "seatStatus:" + scheduleId; + redisTemplate.opsForHash().put(redisHashKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); + log.info("redisHashKey : " + redisHashKey + " = " + " redisSelectedKey : " + redisSelectedKey); + } + + public Map getSeatStatusMap(Long scheduleId) { + + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + + List seatScheduleInfos = + seatScheduleInfoRepository.findAllBySchedule(schedule); + + List fieldKeys = seatScheduleInfos.stream() + .map(info -> info.getId().toString()) + .toList(); + + String redisHashKey = "seatStatus:" + scheduleId; + List redisStatuses = redisTemplate.opsForHash().multiGet(redisHashKey, new ArrayList<>(fieldKeys)); + + Map seatStatusMap = new HashMap<>(); + + for (int i = 0; i < seatScheduleInfos.size(); i++) { + SeatScheduleInfo info = seatScheduleInfos.get(i); + Object redisStatusObj = redisStatuses.get(i); + + String status = redisStatusObj != null + ? redisStatusObj.toString() + : seatScheduleInfos.get(i).getStatus().name(); + + seatStatusMap.put("seatScheduleInfo-" + info.getId().toString(), status); + } + + return seatStatusMap; + } + + public void forceSeatScheduleInfoInRedis(Long scheduleId){ + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + + List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + + String redisHashKey = "seatStatus:" + schedule.getId(); + Map seatStatusMap = new HashMap<>(); + + for (SeatScheduleInfo seat : seatInfos) { + seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); + } + + redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); + } +} diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index bd2c0e8..e280366 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -57,16 +57,4 @@ public RedisTemplate redisJsonTemplate(RedisConnectionFactory co template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화 return template; } - - /** - * String 타입 RedisTemplate - */ - @Bean - public RedisTemplate redisStringTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(connectionFactory); - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - return redisTemplate; - } } diff --git a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java index 8454bad..b4ab1b6 100644 --- a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java +++ b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java @@ -1,28 +1,18 @@ package org.example.siljeun.domain.schedule.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.stream.IntStream; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; import org.example.siljeun.domain.concert.repository.ConcertRepository; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.Seat; -import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.example.siljeun.domain.venue.entity.Venue; import org.example.siljeun.domain.venue.repository.VenueRepository; -import org.example.siljeun.domain.venue.repository.VenueSeatRepository; +import org.example.siljeun.domain.seat.repository.SeatRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -34,92 +24,91 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + @SpringBootTest @AutoConfigureMockMvc @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SeatScheduleInfoServiceTest { - @Autowired - private SeatScheduleInfoService seatScheduleInfoService; - - @Autowired - private SeatScheduleInfoRepository seatScheduleInfoRepository; - - @Autowired - private VenueRepository venueRepository; - - @Autowired - private VenueSeatRepository venueSeatRepository; - - @Autowired - private ScheduleRepository scheduleRepository; - - @Autowired - private ConcertRepository concertRepository; - - @Autowired - @Qualifier("redisLongTemplate") - private RedisTemplate redisTemplate; - - private Seat seat; - private Schedule schedule; - - @BeforeEach - void setUp() { - redisTemplate.getConnectionFactory().getConnection().flushAll(); - Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); - seat = venueSeatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); - Concert concert = concertRepository.save( - new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); - schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), - LocalDateTime.of(2025, 5, 6, 10, 0))); - } - - @Test - @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") - void sameSeatConcurrentAccessTest() throws InterruptedException { - // given - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save( - new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), - seat.getDefaultPrice())); - Long seatScheduleInfoId = seatScheduleInfo.getId(); - int totalThreads = 1000; - ExecutorService executor = Executors.newFixedThreadPool(100); - CountDownLatch latch = new CountDownLatch(totalThreads); - List resultMessages = Collections.synchronizedList(new ArrayList<>()); - - // when - IntStream.range(0, totalThreads).forEach(i -> { - executor.submit(() -> { - try { - seatScheduleInfoService.selectSeat((long) i + 1, "testUser", seatScheduleInfoId); - resultMessages.add("SUCCESS"); - } catch (ResponseStatusException e) { - resultMessages.add(e.getReason()); - } finally { - latch.countDown(); - } - }); - }); - - latch.await(); - - // then - long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); - long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); - - System.out.println("\n성공 요청 수: " + successCount); - System.out.println("실패 요청 수: " + conflictCount); - - assertEquals(1, successCount); - assertEquals(totalThreads - 1, conflictCount); - - SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(); - assertEquals(SeatStatus.SELECTED, updated.getStatus()); - - Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); - assertNotNull(storedUserId); - System.out.println("Redis에 저장된 유저 ID: " + storedUserId); - } + @Autowired + private SeatScheduleInfoService seatScheduleInfoService; + + @Autowired + private SeatScheduleInfoRepository seatScheduleInfoRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private SeatRepository seatRepository; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + @Qualifier("redisLongTemplate") + private RedisTemplate redisTemplate; + + private Seat seat; + private Schedule schedule; + + @BeforeEach + void setUp() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); + seat = seatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); + Concert concert = concertRepository.save(new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); + schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), LocalDateTime.of(2025, 5, 6, 10, 0))); + } + + @Test + @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") + void sameSeatConcurrentAccessTest() throws InterruptedException { + // given + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save(new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), seat.getDefaultPrice())); + Long seatScheduleInfoId = seatScheduleInfo.getId(); + int totalThreads = 1000; + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(totalThreads); + List resultMessages = Collections.synchronizedList(new ArrayList<>()); + + // when + IntStream.range(0, totalThreads).forEach(i -> { + executor.submit(() -> { + try { + seatScheduleInfoService.selectSeat((long) i + 1, schedule.getId(), seatScheduleInfoId); + resultMessages.add("SUCCESS"); + } catch (ResponseStatusException e) { + resultMessages.add(e.getReason()); + } finally { + latch.countDown(); + } + }); + }); + + latch.await(); + + // then + long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); + long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); + + System.out.println("\n성공 요청 수: " + successCount); + System.out.println("실패 요청 수: " + conflictCount); + + assertEquals(1, successCount); + assertEquals(totalThreads - 1, conflictCount); + } } \ No newline at end of file