From 2b79e13b20802e201e4062857feba54ab37e1e30 Mon Sep 17 00:00:00 2001 From: Hanna Lee <8annahxxl@gmail.com> Date: Fri, 12 Jan 2024 01:32:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EB=A7=A4=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=EB=82=B4=EA=B0=80=20=EC=84=A0=EC=A0=90=ED=95=9C?= =?UTF-8?q?=20=EC=A2=8C=EC=84=9D=EC=9D=B8=EC=A7=80=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=88=EB=A7=A4=20=ED=86=A0=ED=81=B0=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: EventSeatStatus BEING_BOOKED -> SELECTED * refactor: CardIssuer 생성자 롬복으로 * fix: 바디 없는 응답 noContent 응답으로 수정 * refactor: getSelectorId Optional 래핑 제거 * chore: booking.http 제거 및 bingterpark 예매 플로우 토큰 변수로 정의 * feat: 예매 생성 시 본인이 선택한 좌석인지 체킹하는 로직 추가 * docs: api-booking http 롤백 * fix: 인터셉터 적용 경로 수정 * feat: getBookableSeatsWithArea selectorId null 체크 추가 * test: redis 포함 로직 mocking 및 좌석 선점 체크 여부 테스트 추가 * test: bookingQueueRepository mockBean 제거한거 롤백 --------- Co-authored-by: byulcode --- api/api-booking/http/booking.http | 60 ++++++------ .../com/pgms/apibooking/config/WebConfig.java | 4 +- .../booking/controller/BookingController.java | 4 +- .../booking/service/BookingService.java | 17 +++- .../controller/BookingQueueController.java | 4 +- .../seat/controller/SeatController.java | 4 +- .../domain/seat/service/SeatLockService.java | 16 ++-- .../domain/seat/service/SeatService.java | 15 ++- .../service/BookingServiceTest.java | 91 +++++++++++++++++-- .../coredomain/domain/booking/CardIssuer.java | 7 +- .../domain/common/BookingErrorCode.java | 32 ++++--- .../domain/event/EventSeatStatus.java | 2 +- http/bingterpark.http | 50 +++++----- http/http-client.env.json | 6 ++ 14 files changed, 197 insertions(+), 115 deletions(-) create mode 100644 http/http-client.env.json diff --git a/api/api-booking/http/booking.http b/api/api-booking/http/booking.http index f750d669..54dcf727 100644 --- a/api/api-booking/http/booking.http +++ b/api/api-booking/http/booking.http @@ -1,12 +1,12 @@ ### 세션 아이디 발급 POST http://localhost:8082/api/v1/bookings/issue-session-id -Authorization: Bearer {{token}} +Authorization: Bearer {{accessToken}} ### 대기열 진입 POST http://localhost:8082/api/v1/bookings/enter-queue Content-Type: application/json -Authorization: Bearer {{token}} -Booking-Session-Id: {sessionId} +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -14,15 +14,15 @@ Booking-Session-Id: {sessionId} ### 대기열 조회 GET http://localhost:8082/api/v1/bookings/order-in-queue?eventId=1 -Authorization: Bearer {{token}} -Booking-Session-Id: {sessionId} +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} ### 예매 토큰 발급 POST http://localhost:8082/api/v1/bookings/issue-token Content-Type: application/json -Authorization: Bearer {{token}} -Booking-Session-Id: {sessionId} +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -31,8 +31,8 @@ Booking-Session-Id: {sessionId} ### [Optional] 대기열 이탈 POST http://localhost:8082/api/v1/bookings/exit-queue Content-Type: application/json -Authorization: Bearer {{token}} -Booking-Session-Id: {sessionId} +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -40,50 +40,42 @@ Booking-Session-Id: {sessionId} ### 좌석 목록 조회 GET http://localhost:8082/api/v1/seats?eventTimeId=1 -Authorization: Bearer {{token}} -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 좌석 선택 POST http://localhost:8082/api/v1/seats/1/select Content-Type: application/json -Authorization: Bearer {{token}} -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB - -{ - "seatId": 1 -} +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 좌석 선택 해제 POST http://localhost:8082/api/v1/seats/1/deselect Content-Type: application/json -Authorization: Bearer {{token}} -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB - -{ - "seatId": 1 -} +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} -### 예매 ~ 결제 (브라우저에서 진행해 주세요) +### 예매 ~ 결제 승인 (브라우저에서 진행해 주세요) GET http://localhost:8082/bookings ### [Optional] 예매 이탈 -POST http://localhost:8082/api/v1/bookings/bookingCreateTestId/exit -Authorization: Bearer {{token}} -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +POST http://localhost:8082/api/v1/bookings/1/exit +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} -### 예매 취소 (브라우저에서 생성된 예매번호로 진행해 주세요) -POST http://localhost:8082/api/v1/bookings/{bookingId}/cancel -Authorization: Bearer {{token}} +### 예매 취소 (브라우저에서 생성된 예매 번호로 진행해 주세요) +POST http://localhost:8082/api/v1/bookings/1/cancel Content-Type: application/json +Authorization: Bearer {{accessToken}} { - "cancelReason": "변심" + "cancelReason": "구매자 변심" } ### 내 예매 내역 목록 조회 GET http://localhost:8082/api/v1/bookings -Authorization: Bearer {{token}} +Authorization: Bearer {{accessToken}} ### 내 예매 내역 상세 조회 -GET http://localhost:8082/api/v1/bookings/bookingCancelTestId -Authorization: Bearer {{token}} +GET http://localhost:8082/api/v1/bookings/{bookingId} +Authorization: Bearer {{accessToken}} diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/config/WebConfig.java b/api/api-booking/src/main/java/com/pgms/apibooking/config/WebConfig.java index 448b07e2..01894e88 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/config/WebConfig.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/config/WebConfig.java @@ -29,7 +29,7 @@ public void addInterceptors(InterceptorRegistry registry) { .addPathPatterns("/api/*/bookings/create") .addPathPatterns("/api/*/exit") .addPathPatterns("/api/*/seats") - .addPathPatterns("/api/*/*/select") - .addPathPatterns("/api/*/*/deselect"); + .addPathPatterns("/api/*/seats/*/select") + .addPathPatterns("/api/*/seats/*/deselect"); } } diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/controller/BookingController.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/controller/BookingController.java index f36f33fe..a628c691 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/controller/BookingController.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/controller/BookingController.java @@ -58,13 +58,13 @@ public ResponseEntity cancelBooking( @PathVariable String id, @RequestBody @Valid BookingCancelRequest request) { bookingService.cancelBooking(id, request, memberId); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PostMapping("/{id}/exit") public ResponseEntity exitBooking(@PathVariable String id) { bookingService.exitBooking(id); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @GetMapping diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/service/BookingService.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/service/BookingService.java index 8a687b1b..fa72beb8 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/service/BookingService.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/booking/service/BookingService.java @@ -27,6 +27,7 @@ import com.pgms.apibooking.domain.payment.dto.request.PaymentCancelRequest; import com.pgms.apibooking.domain.payment.dto.request.RefundAccountRequest; import com.pgms.apibooking.domain.payment.service.PaymentService; +import com.pgms.apibooking.domain.seat.service.SeatLockService; import com.pgms.coredomain.domain.booking.Booking; import com.pgms.coredomain.domain.booking.Payment; import com.pgms.coredomain.domain.booking.PaymentMethod; @@ -59,13 +60,14 @@ public class BookingService { //TODO: 테스트 코드 작성 private final MemberRepository memberRepository; private final BookingQuerydslRepository bookingQuerydslRepository; private final BookingQueueRepository bookingQueueRepository; - private final TossPaymentConfig tossPaymentConfig; + private final SeatLockService seatLockService; private final PaymentService paymentService; + private final TossPaymentConfig tossPaymentConfig; public BookingCreateResponse createBooking(BookingCreateRequest request, Long memberId, String tokenSessionId) { Member member = getMemberById(memberId); EventTime time = getBookableTimeWithEvent(request.timeId()); - List seats = getBookableSeatsWithArea(request.timeId(), request.seatIds()); + List seats = getBookableSeatsWithArea(request.timeId(), request.seatIds(), memberId); ReceiptType receiptType = ReceiptType.fromDescription(request.receiptType()); validateDeliveryAddress(receiptType, request.deliveryAddress()); @@ -178,7 +180,14 @@ private EventTime getBookableTimeWithEvent(Long timeId) { return time; } - private List getBookableSeatsWithArea(Long timeId, List seatIds) { + private List getBookableSeatsWithArea(Long timeId, List seatIds, Long memberId) { + seatIds.forEach(seatId -> { + Long selectorId = seatLockService.getSelectorId(seatId); + if (selectorId == null || !selectorId.equals(memberId)) { + throw new BookingException(BookingErrorCode.UNBOOKABLE_SEAT_INCLUSION); + } + }); + List seats = eventSeatRepository.findAllWithAreaByTimeIdAndSeatIds(timeId, seatIds); if (seats.size() != seatIds.size()) { @@ -203,7 +212,7 @@ private void validateDeliveryAddress(ReceiptType receiptType, Optional refundReceiveAccount) { if (paymentMethod == PaymentMethod.VIRTUAL_ACCOUNT && paymentStatus == PaymentStatus.DONE - &&refundReceiveAccount.isEmpty()){ + && refundReceiveAccount.isEmpty()) { throw new BookingException(BookingErrorCode.REFUND_ACCOUNT_REQUIRED); } } diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/bookingqueue/controller/BookingQueueController.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/bookingqueue/controller/BookingQueueController.java index 72f8007d..d788d98e 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/bookingqueue/controller/BookingQueueController.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/bookingqueue/controller/BookingQueueController.java @@ -37,7 +37,7 @@ public ResponseEntity> issueSessionId() { @PostMapping("/enter-queue") public ResponseEntity enterQueue(@RequestBody @Valid BookingQueueEnterRequest request, @RequestAttribute("bookingSessionId") String bookingSessionId) { bookingQueueService.enterQueue(request, bookingSessionId); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @GetMapping("/order-in-queue") @@ -56,6 +56,6 @@ public ResponseEntity> issueToken(@RequestBody @ @PostMapping("/exit-queue") public ResponseEntity exitQueue(@RequestBody @Valid BookingQueueExitRequest request, @RequestAttribute("bookingSessionId") String bookingSessionId) { bookingQueueService.exitQueue(request, bookingSessionId); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } } diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/controller/SeatController.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/controller/SeatController.java index 5f7b2e65..1553eb4c 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/controller/SeatController.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/controller/SeatController.java @@ -35,12 +35,12 @@ public ResponseEntity>> getSeats(@ModelAttribute @PostMapping("/{seatId}/select") public ResponseEntity selectSeat(@PathVariable Long seatId, @CurrentAccount Long memberId) { seatService.selectSeat(seatId, memberId); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PostMapping("/{seatId}/deselect") public ResponseEntity deselectSeat(@PathVariable Long seatId, @CurrentAccount Long memberId) { seatService.deselectSeat(seatId, memberId); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } } diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatLockService.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatLockService.java index 5dd3b5d8..9c1d782e 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatLockService.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatLockService.java @@ -1,6 +1,7 @@ package com.pgms.apibooking.domain.seat.service; import java.time.Duration; +import java.util.List; import java.util.Optional; import org.springframework.data.redis.core.RedisTemplate; @@ -10,7 +11,7 @@ @Service @RequiredArgsConstructor -class SeatLockService { //TODO: 레디스 레포지토리 분리 +public class SeatLockService { private final static String SEAT_LOCK_CACHE_KEY_PREFIX = "seatId:"; private final static String SEAT_LOCK_CACHE_VALUE_PREFIX = "memberId:"; @@ -18,27 +19,24 @@ class SeatLockService { //TODO: 레디스 레포지토리 분리 private final RedisTemplate redisTemplate; - Optional getSelectorId(Long seatId) { + public Long getSelectorId(Long seatId) { String key = generateSeatLockKey(seatId); String value = redisTemplate.opsForValue().get(key); - if (value == null) { - return Optional.empty(); - } - return Optional.of(extractMemberId(value)); + return value == null ? null : extractMemberId(value); } - boolean isSeatLocked(Long seatId) { + public boolean isSeatLocked(Long seatId) { return redisTemplate.opsForValue().get(generateSeatLockKey(seatId)) != null; } - void lockSeat(Long seatId, Long memberId) { + public void lockSeat(Long seatId, Long memberId) { String key = generateSeatLockKey(seatId); String value = generateSeatLockValue(memberId); Duration timeout = Duration.ofSeconds(SEAT_LOCK_CACHE_EXPIRE_SECONDS); redisTemplate.opsForValue().setIfAbsent(key, value, timeout); } - void unlockSeat(Long seatId) { + public void unlockSeat(Long seatId) { redisTemplate.delete(generateSeatLockKey(seatId)); } diff --git a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatService.java b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatService.java index 2a4a5d01..05d2c6d6 100644 --- a/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatService.java +++ b/api/api-booking/src/main/java/com/pgms/apibooking/domain/seat/service/SeatService.java @@ -1,7 +1,6 @@ package com.pgms.apibooking.domain.seat.service; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -36,13 +35,13 @@ public List getSeats(SeatsGetRequest request) { public void selectSeat(Long seatId, Long memberId) { if (seatLockService.isSeatLocked(seatId)) { - Optional selectorIdOpt = seatLockService.getSelectorId(seatId); + Long selectorId = seatLockService.getSelectorId(seatId); - if (selectorIdOpt.isPresent() && selectorIdOpt.get().equals(memberId)) { + if (selectorId != null && selectorId.equals(memberId)) { return; } - throw new BookingException(BookingErrorCode.SEAT_BEING_BOOKED); + throw new BookingException(BookingErrorCode.SEAT_SELECTED_BY_ANOTHER_MEMBER); } EventSeat seat = getSeat(seatId); @@ -51,19 +50,19 @@ public void selectSeat(Long seatId, Long memberId) { throw new BookingException(BookingErrorCode.SEAT_ALREADY_BOOKED); } - seat.updateStatus(EventSeatStatus.BEING_BOOKED); + seat.updateStatus(EventSeatStatus.SELECTED); seatLockService.lockSeat(seatId, memberId); } public void deselectSeat(Long seatId, Long memberId) { - Optional selectorIdOpt = seatLockService.getSelectorId(seatId); + Long selectorId = seatLockService.getSelectorId(seatId); - if (selectorIdOpt.isEmpty()) { + if (selectorId == null) { updateSeatStatusToAvailable(seatId); return; } - if (!selectorIdOpt.get().equals(memberId)) { + if (!selectorId.equals(memberId)) { throw new BookingException(BookingErrorCode.SEAT_SELECTED_BY_ANOTHER_MEMBER); } diff --git a/api/api-booking/src/test/java/com/pgms/apibooking/service/BookingServiceTest.java b/api/api-booking/src/test/java/com/pgms/apibooking/service/BookingServiceTest.java index 966a328c..bdf9f4d3 100644 --- a/api/api-booking/src/test/java/com/pgms/apibooking/service/BookingServiceTest.java +++ b/api/api-booking/src/test/java/com/pgms/apibooking/service/BookingServiceTest.java @@ -1,6 +1,7 @@ package com.pgms.apibooking.service; import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; import java.time.LocalDateTime; import java.util.List; @@ -15,7 +16,6 @@ import org.springframework.context.annotation.Import; import org.springframework.transaction.annotation.Transactional; -import com.pgms.coredomain.domain.common.BookingErrorCode; import com.pgms.apibooking.common.exception.BookingException; import com.pgms.apibooking.config.TestConfig; import com.pgms.apibooking.domain.booking.dto.request.BookingCancelRequest; @@ -24,6 +24,7 @@ import com.pgms.apibooking.domain.booking.service.BookingService; import com.pgms.apibooking.domain.bookingqueue.repository.BookingQueueRepository; import com.pgms.apibooking.domain.payment.dto.request.RefundAccountRequest; +import com.pgms.apibooking.domain.seat.service.SeatLockService; import com.pgms.apibooking.factory.BookingFactory; import com.pgms.apibooking.factory.EventFactory; import com.pgms.apibooking.factory.EventHallFactory; @@ -41,6 +42,7 @@ import com.pgms.coredomain.domain.booking.ReceiptType; import com.pgms.coredomain.domain.booking.Ticket; import com.pgms.coredomain.domain.booking.repository.BookingRepository; +import com.pgms.coredomain.domain.common.BookingErrorCode; import com.pgms.coredomain.domain.event.Event; import com.pgms.coredomain.domain.event.EventHall; import com.pgms.coredomain.domain.event.EventSeat; @@ -63,6 +65,7 @@ class BookingServiceTest { private static final LocalDateTime NOW = LocalDateTime.now(); + private static final String SESSION_ID = UUID.randomUUID().toString(); @Autowired private EventHallRepository eventHallRepository; @@ -84,13 +87,16 @@ class BookingServiceTest { @Autowired private BookingService bookingService; - + @Autowired private MemberRepository memberRepository; @MockBean private BookingQueueRepository bookingQueueRepository; + @MockBean + private SeatLockService seatLockService; + private Member member; @BeforeEach @@ -155,8 +161,11 @@ void setup() { Optional.empty() ); + given(seatLockService.getSelectorId(any(Long.class))).willReturn(member.getId()); + doNothing().when(bookingQueueRepository).remove(any(Long.class), any(String.class)); + // when - BookingCreateResponse response = bookingService.createBooking(request, member.getId(), UUID.randomUUID().toString()); + BookingCreateResponse response = bookingService.createBooking(request, member.getId(), SESSION_ID); // then Booking booking = bookingRepository.findBookingInfoById(response.bookingId()).get(); @@ -182,6 +191,66 @@ void setup() { .containsOnly(seat1Name, seat2Name); } + @Test + void 내가_선점한_좌석이_아니면_예매를_생성할_수_없다() { + // given + LocalDateTime eventStartedAt = NOW.plusDays(2); + LocalDateTime eventEndedAt = NOW.plusDays(2).plusMinutes(120); + LocalDateTime bookingStartedAt = NOW; + LocalDateTime bookingEndedAt = NOW.plusDays(1); + + EventHall hall = EventHallFactory.generate(); + eventHallRepository.save(hall); + + Event event = EventFactory.generate( + hall, + eventStartedAt, + eventEndedAt, + bookingStartedAt, + bookingEndedAt + ); + eventRepository.save(event); + + EventTime time = EventTimeFactory.generate(event, eventStartedAt, eventEndedAt); + eventTimeRepository.save(time); + + EventSeatArea area1 = EventSeatAreaFactory.generate(event, SeatAreaType.S); + eventSeatAreaRepository.save(area1); + EventSeatArea area2 = EventSeatAreaFactory.generate(event, SeatAreaType.R); + eventSeatAreaRepository.save(area2); + + String seat1Name = "A1"; + String seat2Name = "A2"; + + EventSeat seat1 = EventSeatFactory.generate(time, area1, seat1Name, EventSeatStatus.AVAILABLE); + eventSeatRepository.save(seat1); + EventSeat seat2 = EventSeatFactory.generate(time, area2, seat2Name, EventSeatStatus.AVAILABLE); + eventSeatRepository.save(seat2); + + Long timeId = time.getId(); + List seatIds = List.of(seat1.getId(), seat2.getId()); + String receiptType = ReceiptType.PICK_UP.getDescription(); + String buyerName = "구매자 명"; + String buyerPhoneNumber = "010-1234-5678"; + + BookingCreateRequest request = new BookingCreateRequest( + timeId, + seatIds, + receiptType, + buyerName, + buyerPhoneNumber, + Optional.empty() + ); + + given(seatLockService.getSelectorId(seat1.getId())).willReturn(null); + given(seatLockService.getSelectorId(seat2.getId())).willReturn(member.getId() + 1); + + // when & then + assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), SESSION_ID)) + .isInstanceOf(BookingException.class) + .hasMessage(BookingErrorCode.UNBOOKABLE_SEAT_INCLUSION.getMessage()); + } + @Test void 예매_기간이_아니면_예매를_생성할_수_없다() { // given @@ -226,8 +295,10 @@ void setup() { Optional.empty() ); + given(seatLockService.getSelectorId(seat.getId())).willReturn(member.getId()); + // when & then - assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), UUID.randomUUID().toString())) + assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), SESSION_ID)) .isInstanceOf(BookingException.class) .hasMessage(BookingErrorCode.UNBOOKABLE_EVENT.getMessage()); } @@ -278,8 +349,10 @@ void setup() { Optional.empty() ); + given(seatLockService.getSelectorId(seat.getId())).willReturn(member.getId()); + // when & then - assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), UUID.randomUUID().toString())) + assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), SESSION_ID)) .isInstanceOf(BookingException.class) .hasMessage(BookingErrorCode.NON_EXISTENT_SEAT_INCLUSION.getMessage()); } @@ -328,8 +401,10 @@ void setup() { Optional.empty() ); + given(seatLockService.getSelectorId(seat.getId())).willReturn(member.getId()); + // when & then - assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), UUID.randomUUID().toString())) + assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), SESSION_ID)) .isInstanceOf(BookingException.class) .hasMessage(BookingErrorCode.UNBOOKABLE_SEAT_INCLUSION.getMessage()); } @@ -378,8 +453,10 @@ void setup() { Optional.empty() ); + given(seatLockService.getSelectorId(seat.getId())).willReturn(member.getId()); + // when & then - assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), UUID.randomUUID().toString())) + assertThatThrownBy(() -> bookingService.createBooking(request, member.getId(), SESSION_ID)) .isInstanceOf(BookingException.class) .hasMessage(BookingErrorCode.DELIVERY_ADDRESS_REQUIRED.getMessage()); } diff --git a/core/core-domain/src/main/java/com/pgms/coredomain/domain/booking/CardIssuer.java b/core/core-domain/src/main/java/com/pgms/coredomain/domain/booking/CardIssuer.java index f6b8ba2d..1f7dd93b 100644 --- a/core/core-domain/src/main/java/com/pgms/coredomain/domain/booking/CardIssuer.java +++ b/core/core-domain/src/main/java/com/pgms/coredomain/domain/booking/CardIssuer.java @@ -1,8 +1,10 @@ package com.pgms.coredomain.domain.booking; import lombok.Getter; +import lombok.RequiredArgsConstructor; @Getter +@RequiredArgsConstructor public enum CardIssuer { IBK_BC("기업 BC", "3K"), GWANGJUBANK("광주은행", "46"), @@ -38,11 +40,6 @@ public enum CardIssuer { private final String name; private final String officialCode; - CardIssuer(String name, String officialCode) { - this.name = name; - this.officialCode = officialCode; - } - public static CardIssuer fromOfficialCode(String officialCode) { for (CardIssuer cardIssuer : CardIssuer.values()) { if (cardIssuer.officialCode.equals(officialCode)) { diff --git a/core/core-domain/src/main/java/com/pgms/coredomain/domain/common/BookingErrorCode.java b/core/core-domain/src/main/java/com/pgms/coredomain/domain/common/BookingErrorCode.java index 1ef6ff55..bb3a2ac6 100644 --- a/core/core-domain/src/main/java/com/pgms/coredomain/domain/common/BookingErrorCode.java +++ b/core/core-domain/src/main/java/com/pgms/coredomain/domain/common/BookingErrorCode.java @@ -10,39 +10,43 @@ @Getter @RequiredArgsConstructor public enum BookingErrorCode implements BaseErrorCode{ + + // seat SEAT_NOT_FOUND(HttpStatus.BAD_REQUEST, "SEAT_NOT_FOUND", "존재하지 않는 좌석입니다."), - SEAT_BEING_BOOKED(HttpStatus.BAD_REQUEST, "SEAT_BEING_BOOKED", "예매중인 좌석입니다."), SEAT_ALREADY_BOOKED(HttpStatus.BAD_REQUEST, "SEAT_ALREADY_BOOKED", "예매된 좌석입니다."), - SEAT_SELECTED_BY_ANOTHER_MEMBER(HttpStatus.BAD_REQUEST, "SEAT_SELECTED_BY_ANOTHER_MEMBER", "다른 회원이 선택한 좌석입니다."), + SEAT_SELECTED_BY_ANOTHER_MEMBER(HttpStatus.BAD_REQUEST, "SEAT_SELECTED_BY_ANOTHER_MEMBER", "예매중인 좌석입니다."), SEAT_SELECTION_EXPIRED(HttpStatus.BAD_REQUEST, "SEAT_SELECTION_EXPIRED", "좌석 선택 시간이 만료되었습니다."), + // booking + BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING_NOT_FOUND", "존재하지 않는 예매입니다."), + // booking - create TIME_NOT_FOUND(HttpStatus.BAD_REQUEST, "TIME_NOT_FOUND", "존재하지 않는 공연 회차입니다."), UNBOOKABLE_EVENT(HttpStatus.BAD_REQUEST, "UNBOOKABLE_EVENT", "현재 예매가 불가능한 공연입니다."), NON_EXISTENT_SEAT_INCLUSION(HttpStatus.BAD_REQUEST, "NON_EXISTENT_SEAT_INCLUSION", "선택한 공연 회차에 존재하지 않는 좌석이 포함되어 있습니다."), UNBOOKABLE_SEAT_INCLUSION(HttpStatus.BAD_REQUEST, "UNBOOKABLE_SEAT_INCLUSION", "예매가 불가능한 좌석이 포함되어 있습니다."), DELIVERY_ADDRESS_REQUIRED(HttpStatus.BAD_REQUEST, "DELIVERY_ADDRESS_REQUIRED", "배송지 정보를 입력해주세요."), - + // booking - cancel UNCANCELABLE_BOOKING(HttpStatus.BAD_REQUEST, "UNCANCELABLE_BOOKING", "취소할 수 없는 예매입니다."), REFUND_ACCOUNT_REQUIRED(HttpStatus.BAD_REQUEST, "REFUND_ACCOUNT_REQUIRED", "환불 받을 계좌 정보를 입력해주세요."), - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "INVALID_INPUT_VALUE", "입력값을 확인해 주세요."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 에러"), + // bookingQueue + OUT_OF_ORDER(HttpStatus.BAD_REQUEST, "OUT_OF_ORDER", "예매 순서가 아닙니다."), + NOT_IN_QUEUE(HttpStatus.BAD_REQUEST, "NOT_IN_QUEUE", "대기열에 존재하지 않습니다."), + BOOKING_SESSION_ID_NOT_EXIST(HttpStatus.BAD_REQUEST, "BOOKING_SESSION_ID_NOT_EXIST", "예매 세션 ID가 존재하지 않습니다."), + BOOKING_TOKEN_NOT_EXIST(HttpStatus.UNAUTHORIZED, "BOOKING_TOKEN_NOT_EXIST", "예매 토큰이 존재하지 않습니다."), + INVALID_BOOKING_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_BOOKING_TOKEN", "올바르지 않은 예매 토큰입니다."), + // payment PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_NOT_FOUND", "존재하지 않는 결제입니다."), PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "PAYMENT_AMOUNT_MISMATCH", "결제 가격 정보가 일치하지 않습니다."), INVALID_PAYMENT_METHOD(HttpStatus.BAD_REQUEST, "INVALID_PAYMENT_METHOD", "결제 수단이 올바르지 않습니다."), ACCOUNT_TRANSFER_ERROR(HttpStatus.BAD_REQUEST, "ACCOUNT_TRANSFER_ERROR", "계좌 송금 오류가 발생했습니다. 확인 후 다시 시도해주세요."), TOSS_PAYMENTS_ERROR(HttpStatus.BAD_REQUEST, "TOSS_PAYMENTS_CLIENT_ERROR", "Toss Payments API 오류가 발생했습니다."), - BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING_NOT_FOUND", "존재하지 않는 예매입니다."), - - BOOKING_SESSION_ID_NOT_EXIST(HttpStatus.BAD_REQUEST, "BOOKING_SESSION_ID_NOT_EXIST", "예매 세션 ID가 존재하지 않습니다."), - BOOKING_TOKEN_NOT_EXIST(HttpStatus.UNAUTHORIZED, "BOOKING_TOKEN_NOT_EXIST", "예매 토큰이 존재하지 않습니다."), - INVALID_BOOKING_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_BOOKING_TOKEN", "올바르지 않은 예매 토큰입니다."), - OUT_OF_ORDER(HttpStatus.BAD_REQUEST, "OUT_OF_ORDER", "예매 순서가 아닙니다."), - NOT_IN_QUEUE(HttpStatus.BAD_REQUEST, "NOT_IN_QUEUE", "대기열에 존재하지 않습니다."), - - FORBIDDEN(HttpStatus.FORBIDDEN, "BOOKER_NOT_SAME", "권한이 없습니다."); + // common + FORBIDDEN(HttpStatus.FORBIDDEN, "BOOKER_NOT_SAME", "권한이 없습니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "INVALID_INPUT_VALUE", "입력값을 확인해 주세요."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 에러"); private final HttpStatus status; private final String code; diff --git a/core/core-domain/src/main/java/com/pgms/coredomain/domain/event/EventSeatStatus.java b/core/core-domain/src/main/java/com/pgms/coredomain/domain/event/EventSeatStatus.java index 7319057b..dbb79e8a 100644 --- a/core/core-domain/src/main/java/com/pgms/coredomain/domain/event/EventSeatStatus.java +++ b/core/core-domain/src/main/java/com/pgms/coredomain/domain/event/EventSeatStatus.java @@ -5,7 +5,7 @@ @Getter public enum EventSeatStatus { AVAILABLE("예매가능"), - BEING_BOOKED("예매중"), + SELECTED("예매중"), BOOKED("예매완료"); private final String description; diff --git a/http/bingterpark.http b/http/bingterpark.http index 18e3044b..3171ecc9 100644 --- a/http/bingterpark.http +++ b/http/bingterpark.http @@ -166,13 +166,13 @@ GET http://localhost:8080/api/v1/event-seats/event-times/2/available-numbers ### 세션 아이디 발급 POST http://localhost:8082/api/v1/bookings/issue-session-id -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ +Authorization: Bearer {{accessToken}} ### 대기열 진입 POST http://localhost:8082/api/v1/bookings/enter-queue Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Session-Id: sessionId +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -180,15 +180,15 @@ Booking-Session-Id: sessionId ### 대기열 조회 GET http://localhost:8082/api/v1/bookings/order-in-queue?eventId=1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Session-Id: sessionId +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} ### 예매 토큰 발급 POST http://localhost:8082/api/v1/bookings/issue-token Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Session-Id: sessionId +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -197,8 +197,8 @@ Booking-Session-Id: sessionId ### [Optional] 대기열 이탈 POST http://localhost:8082/api/v1/bookings/exit-queue Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Session-Id: sessionId +Authorization: Bearer {{accessToken}} +Booking-Session-Id: {{sessionId}} { "eventId": 1 @@ -206,33 +206,33 @@ Booking-Session-Id: sessionId ### 좌석 목록 조회 GET http://localhost:8082/api/v1/seats?eventTimeId=1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 좌석 선택 -POST http://localhost:8082/api/v1/seats/{{seatId}}/select +POST http://localhost:8082/api/v1/seats/1/select Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 좌석 선택 해제 -POST http://localhost:8082/api/v1/seats/{{seatId}}/deselect +POST http://localhost:8082/api/v1/seats/1/deselect Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 예매 ~ 결제 승인 (브라우저에서 진행해 주세요) GET http://localhost:8082/bookings ### [Optional] 예매 이탈 -POST http://localhost:8082/api/v1/bookings/{{bookingId}}/exit -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ -Booking-Authorization: Bearer eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB +POST http://localhost:8082/api/v1/bookings/1/exit +Authorization: Bearer {{accessToken}} +Booking-Authorization: Bearer {{bookingToken}} ### 예매 취소 (브라우저에서 생성된 예매 번호로 진행해 주세요) -POST http://localhost:8082/api/v1/bookings/{{bookingId}}/cancel -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ +POST http://localhost:8082/api/v1/bookings/1/cancel Content-Type: application/json +Authorization: Bearer {{accessToken}} { "cancelReason": "구매자 변심" @@ -240,8 +240,8 @@ Content-Type: application/json ### 내 예매 내역 목록 조회 GET http://localhost:8082/api/v1/bookings -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ +Authorization: Bearer {{accessToken}} ### 내 예매 내역 상세 조회 -GET http://localhost:8082/api/v1/bookings/{{bookingId}} -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ +GET http://localhost:8082/api/v1/bookings/{bookingId} +Authorization: Bearer {{accessToken}} diff --git a/http/http-client.env.json b/http/http-client.env.json new file mode 100644 index 00000000..74011977 --- /dev/null +++ b/http/http-client.env.json @@ -0,0 +1,6 @@ +{ + "dev": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2OTE0OTEsImV4cCI6MTgwNDY4MzI5MSwiYXV0aG9yaXR5IjoiUk9MRV9VU0VSIn0.wFNSz2uwRa35jP1KihNlTOewVLgMMeg3ADQ5Kztl3QQ", + "bookingToken": "eyJhbGciOiJIUzM4NCJ9.eyJpc3MiOiJib29raW5nIiwiaWF0IjoxNzA0NzA0NTY0LCJleHAiOjQyMDAxNzA0NzA0NTY0LCJzZXNzaW9uSWQiOiJ7Pz8_Pz99In0.iKZaud5vfvsMzXmQzl1WaCweL9GL00U0iGzCg4_p3Zzei8Y7z19Ff_TTpxh9gLeB" + } +}