Skip to content

Commit

Permalink
feat: 예매 생성 시 내가 선점한 좌석인지 검증하는 로직 추가 및 예매 토큰 인터셉터 버그 수정 (#228)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
annahxxl and byulcode committed Jan 11, 2024
1 parent 19e5bfb commit 2b79e13
Show file tree
Hide file tree
Showing 14 changed files with 197 additions and 115 deletions.
60 changes: 26 additions & 34 deletions api/api-booking/http/booking.http
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
### 세션 아이디 발급
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
}

### 대기열 조회
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
Expand All @@ -31,59 +31,51 @@ 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
}

### 좌석 목록 조회
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}}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ public ResponseEntity<Void> 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<Void> exitBooking(@PathVariable String id) {
bookingService.exitBooking(id);
return ResponseEntity.ok().build();
return ResponseEntity.noContent().build();
}

@GetMapping
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<EventSeat> seats = getBookableSeatsWithArea(request.timeId(), request.seatIds());
List<EventSeat> seats = getBookableSeatsWithArea(request.timeId(), request.seatIds(), memberId);

ReceiptType receiptType = ReceiptType.fromDescription(request.receiptType());
validateDeliveryAddress(receiptType, request.deliveryAddress());
Expand Down Expand Up @@ -178,7 +180,14 @@ private EventTime getBookableTimeWithEvent(Long timeId) {
return time;
}

private List<EventSeat> getBookableSeatsWithArea(Long timeId, List<Long> seatIds) {
private List<EventSeat> getBookableSeatsWithArea(Long timeId, List<Long> 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<EventSeat> seats = eventSeatRepository.findAllWithAreaByTimeIdAndSeatIds(timeId, seatIds);

if (seats.size() != seatIds.size()) {
Expand All @@ -203,7 +212,7 @@ private void validateDeliveryAddress(ReceiptType receiptType, Optional<DeliveryA
private void validateRefundReceiveAccount(PaymentMethod paymentMethod, PaymentStatus paymentStatus,
Optional<RefundAccountRequest> refundReceiveAccount) {
if (paymentMethod == PaymentMethod.VIRTUAL_ACCOUNT && paymentStatus == PaymentStatus.DONE
&&refundReceiveAccount.isEmpty()){
&& refundReceiveAccount.isEmpty()) {
throw new BookingException(BookingErrorCode.REFUND_ACCOUNT_REQUIRED);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public ResponseEntity<ApiResponse<SessionIdIssueResponse>> issueSessionId() {
@PostMapping("/enter-queue")
public ResponseEntity<Void> 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")
Expand All @@ -56,6 +56,6 @@ public ResponseEntity<ApiResponse<TokenIssueResponse>> issueToken(@RequestBody @
@PostMapping("/exit-queue")
public ResponseEntity<Void> exitQueue(@RequestBody @Valid BookingQueueExitRequest request, @RequestAttribute("bookingSessionId") String bookingSessionId) {
bookingQueueService.exitQueue(request, bookingSessionId);
return ResponseEntity.ok().build();
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ public ResponseEntity<ApiResponse<List<AreaResponse>>> getSeats(@ModelAttribute
@PostMapping("/{seatId}/select")
public ResponseEntity<Void> selectSeat(@PathVariable Long seatId, @CurrentAccount Long memberId) {
seatService.selectSeat(seatId, memberId);
return ResponseEntity.ok().build();
return ResponseEntity.noContent().build();
}

@PostMapping("/{seatId}/deselect")
public ResponseEntity<Void> deselectSeat(@PathVariable Long seatId, @CurrentAccount Long memberId) {
seatService.deselectSeat(seatId, memberId);
return ResponseEntity.ok().build();
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,35 +11,32 @@

@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:";
private final static int SEAT_LOCK_CACHE_EXPIRE_SECONDS = 420;

private final RedisTemplate<String, String> redisTemplate;

Optional<Long> 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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,13 +35,13 @@ public List<AreaResponse> getSeats(SeatsGetRequest request) {

public void selectSeat(Long seatId, Long memberId) {
if (seatLockService.isSeatLocked(seatId)) {
Optional<Long> 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);
Expand All @@ -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<Long> 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);
}

Expand Down
Loading

0 comments on commit 2b79e13

Please sign in to comment.