Skip to content

Commit 94988f6

Browse files
authored
[feat] 좌석선택취소 api 구현, draft ticket 발급구조 리팩터링 - (event-user)유일하게 유지
* feat: 좌석선택취소 api 구현, draft ticket 발급구조 리팩터링 - (event-user)유일하게 유지 * test: service코드 구조 변경에 따른 테스트 리펙토링 * test: CI 실패 테스트 getOrCreateDraft_reuse_success() 수정
1 parent 875c234 commit 94988f6

9 files changed

Lines changed: 255 additions & 100 deletions

File tree

backend/src/main/java/com/back/api/selection/controller/SeatSelectionApi.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,17 @@ public ApiResponse<SeatSelectionResponse> selectSeat(
2727
@PathVariable Long eventId,
2828
@PathVariable Long seatId
2929
);
30+
31+
@Operation(summary = "좌석 선택 취소", description = "선택한 좌석을 취소하고 AVAILABLE 상태로 복구합니다. Draft Ticket은 유지됩니다.")
32+
@ApiErrorCode(
33+
{
34+
"NOT_FOUND_SEAT",
35+
"SEAT_NOT_SELECTED",
36+
"NOT_FOUND_USER",
37+
}
38+
)
39+
public ApiResponse<Void> deselectSeat(
40+
@PathVariable Long eventId,
41+
@PathVariable Long seatId
42+
);
3043
}

backend/src/main/java/com/back/api/selection/controller/SeatSelectionController.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.api.selection.controller;
22

3+
import org.springframework.web.bind.annotation.DeleteMapping;
34
import org.springframework.web.bind.annotation.PathVariable;
45
import org.springframework.web.bind.annotation.PostMapping;
56
import org.springframework.web.bind.annotation.RequestMapping;
@@ -40,4 +41,20 @@ public ApiResponse<SeatSelectionResponse> selectSeat(
4041
SeatSelectionResponse.from(draftTicket)
4142
);
4243
}
44+
45+
/**
46+
* 좌석 선택 취소
47+
* DELETE /api/v1/events/{eventId}/seats/{seatId}/deselect
48+
*/
49+
@Override
50+
@DeleteMapping("/deselect")
51+
public ApiResponse<Void> deselectSeat(
52+
@PathVariable Long eventId, @PathVariable Long seatId
53+
) {
54+
Long userId = httpRequestContext.getUser().getId();
55+
56+
seatSelectionService.deselectSeatAndCancelTicket(eventId, seatId, userId);
57+
58+
return ApiResponse.noContent("좌석 선택이 취소되었습니다.");
59+
}
4360
}

backend/src/main/java/com/back/api/selection/service/SeatSelectionService.java

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,51 @@ public class SeatSelectionService {
2525
private final QueueEntryReadService queueEntryReadService;
2626

2727
/**
28-
* 좌석 선택 + DraftTicket 생성
28+
* 좌석 선택 + DraftTicket 생성/업데이트
29+
* - 기존 Draft가 있으면 재사용 (좌석만 변경)
30+
* - 없으면 새로 생성
2931
*/
3032
@Transactional
3133
public Ticket selectSeatAndCreateTicket(Long eventId, Long seatId, Long userId) {
3234
if (!queueEntryReadService.isUserEntered(eventId, userId)) {
3335
throw new ErrorException(SeatErrorCode.NOT_IN_QUEUE);
3436
}
3537

36-
// 좌석 여러개 선택 방어 로직
37-
if (ticketService.hasUserAlreadySelectedSeat(eventId, userId)) {
38-
throw new ErrorException(SeatErrorCode.SEAT_ALREADY_EXISTS);
38+
// Draft Ticket 조회 또는 생성 (1개 보장)
39+
Ticket ticket = ticketService.getOrCreateDraft(eventId, userId);
40+
41+
// 기존 좌석이 있으면 먼저 해제
42+
if (ticket.hasSeat()) {
43+
seatService.markSeatAsAvailable(ticket.getSeat());
3944
}
4045

41-
Seat reservedSeat = seatService.reserveSeat(eventId, seatId, userId);
46+
// 새 좌석 예약
47+
Seat newSeat = seatService.reserveSeat(eventId, seatId, userId);
48+
49+
// Ticket에 좌석 할당
50+
ticket.assignSeat(newSeat);
51+
52+
return ticket;
53+
}
54+
55+
/**
56+
* 좌석 선택 취소 (DraftTicket은 유지, 좌석만 해제)
57+
*/
58+
@Transactional
59+
public void deselectSeatAndCancelTicket(Long eventId, Long seatId, Long userId) {
60+
// Draft Ticket 조회
61+
Ticket ticket = ticketService.getOrCreateDraft(eventId, userId);
62+
63+
// 좌석 검증
64+
if (!ticket.hasSeat() || !ticket.getSeat().getId().equals(seatId)) {
65+
throw new ErrorException(SeatErrorCode.SEAT_NOT_SELECTED);
66+
}
4267

43-
Ticket draftTicket = ticketService.createDraftTicket(eventId, seatId, userId);
68+
// 좌석 해제
69+
Seat seat = ticket.getSeat();
70+
seatService.markSeatAsAvailable(seat);
4471

45-
return draftTicket;
72+
// Ticket에서 좌석 제거 (티켓은 유지)
73+
ticket.clearSeat();
4674
}
4775
}

backend/src/main/java/com/back/api/ticket/service/TicketService.java

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,33 @@ public Ticket createDraftTicket(Long eventId, Long seatId, Long userId) {
7575
return ticketRepository.save(ticket);
7676
}
7777

78+
/**
79+
* Draft Ticket 조회 또는 생성 (유저+이벤트당 1개 유지)
80+
* - 기존 Draft가 있으면 반환
81+
* - 없으면 새로 생성 (좌석 없이)
82+
*/
83+
@Transactional
84+
public Ticket getOrCreateDraft(Long eventId, Long userId) {
85+
return ticketRepository
86+
.findByEventIdAndOwnerIdAndTicketStatus(eventId, userId, TicketStatus.DRAFT)
87+
.orElseGet(() -> {
88+
User user = userRepository.findById(userId)
89+
.orElseThrow(() -> new ErrorException(CommonErrorCode.NOT_FOUND_USER));
90+
91+
Event event = eventRepository.findById(eventId)
92+
.orElseThrow(() -> new ErrorException(EventErrorCode.NOT_FOUND_EVENT));
93+
94+
Ticket ticket = Ticket.builder()
95+
.owner(user)
96+
.event(event)
97+
.seat(null) // 좌석 없이 생성
98+
.ticketStatus(TicketStatus.DRAFT)
99+
.build();
100+
101+
return ticketRepository.save(ticket);
102+
});
103+
}
104+
78105
/**
79106
* 진행 중인 Draft Ticket 조회
80107
*/
@@ -153,16 +180,4 @@ public Ticket getTicketDetail(Long ticketId, Long userId) {
153180

154181
return ticket;
155182
}
156-
157-
/**
158-
* 사용자가 이미 해당 이벤트에 대해 좌석을 선택(임시/발급완료 된 티켓이 존재)했는지 확인
159-
*/
160-
@Transactional(readOnly = true)
161-
public boolean hasUserAlreadySelectedSeat(Long eventId, Long userId) {
162-
return ticketRepository.existsByEventIdAndOwnerIdAndTicketStatusIn(
163-
eventId,
164-
userId,
165-
List.of(TicketStatus.DRAFT, TicketStatus.PAID, TicketStatus.ISSUED)
166-
);
167-
}
168183
}

backend/src/main/java/com/back/domain/ticket/entity/Ticket.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public class Ticket extends BaseEntity {
4747
private User owner;
4848

4949
@ManyToOne(fetch = FetchType.LAZY)
50-
@JoinColumn(name = "seat_id", nullable = false)
50+
@JoinColumn(name = "seat_id", nullable = true)
5151
private Seat seat;
5252

5353
@ManyToOne(fetch = FetchType.LAZY)
@@ -129,4 +129,31 @@ public void cancel() {
129129
}
130130
this.ticketStatus = TicketStatus.CANCELLED;
131131
}
132+
133+
/**
134+
* 좌석 할당 (DRAFT 티켓에만 가능)
135+
*/
136+
public void assignSeat(Seat seat) {
137+
if (this.ticketStatus != TicketStatus.DRAFT) {
138+
throw new ErrorException(TicketErrorCode.INVALID_TICKET_STATE);
139+
}
140+
this.seat = seat;
141+
}
142+
143+
/**
144+
* 좌석 해제 (DRAFT 티켓에만 가능)
145+
*/
146+
public void clearSeat() {
147+
if (this.ticketStatus != TicketStatus.DRAFT) {
148+
throw new ErrorException(TicketErrorCode.INVALID_TICKET_STATE);
149+
}
150+
this.seat = null;
151+
}
152+
153+
/**
154+
* 좌석 할당 여부 확인
155+
*/
156+
public boolean hasSeat() {
157+
return this.seat != null;
158+
}
132159
}

backend/src/main/java/com/back/domain/ticket/repository/TicketRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
2424
List<Ticket> findExpiredDraftTickets(TicketStatus status, LocalDateTime time);
2525

2626
Optional<Ticket> findBySeatIdAndOwnerIdAndTicketStatus(Long seatId, Long userId, TicketStatus ticketStatus);
27+
28+
Optional<Ticket> findByEventIdAndOwnerIdAndTicketStatus(Long eventId, Long userId, TicketStatus ticketStatus);
2729
}

backend/src/main/java/com/back/global/error/code/SeatErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public enum SeatErrorCode implements ErrorCode {
2020
SEAT_CONCURRENCY_FAILURE(HttpStatus.BAD_REQUEST, "다른 사용자가 해당 좌석을 선택하는 중입니다. 다시 시도해주세요."),
2121

2222
SEAT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "1개 이상 좌석을 선택할 수 없습니다."),
23+
SEAT_NOT_SELECTED(HttpStatus.BAD_REQUEST, "선택된 좌석이 없거나 다른 좌석이 선택되어 있습니다."),
2324

2425
// 관리자 예외
2526
DUPLICATE_SEAT_CODE(HttpStatus.BAD_REQUEST, "이미 존재하는 좌석 코드가 포함되어 있습니다.");

backend/src/test/java/com/back/api/selection/service/SeatSelectServiceUnitTest.java

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,19 @@ void setUp() {
8383
class SelectSeatAndCreateTicketTest {
8484

8585
@Test
86-
@DisplayName("정상적으로 좌석을 선택하고 Draft Ticket을 생성한다")
86+
@DisplayName("정상적으로 좌석을 선택하고 Draft Ticket을 생성/업데이트한다")
8787
void selectSeatAndCreateTicket_Success() {
8888
// given
89+
Ticket draftTicket = Ticket.builder()
90+
.owner(testUser)
91+
.event(testEvent)
92+
.seat(null) // 좌석 없이 생성
93+
.ticketStatus(TicketStatus.DRAFT)
94+
.build();
95+
8996
given(queueEntryReadService.isUserEntered(eventId, userId)).willReturn(true);
97+
given(ticketService.getOrCreateDraft(eventId, userId)).willReturn(draftTicket);
9098
given(seatService.reserveSeat(eventId, seatId, userId)).willReturn(testSeat);
91-
given(ticketService.createDraftTicket(eventId, seatId, userId)).willReturn(testTicket);
9299

93100
// when
94101
Ticket result = seatSelectionService.selectSeatAndCreateTicket(eventId, seatId, userId);
@@ -101,8 +108,9 @@ void selectSeatAndCreateTicket_Success() {
101108
assertThat(result.getSeat()).isEqualTo(testSeat);
102109

103110
then(queueEntryReadService).should().isUserEntered(eventId, userId);
111+
then(ticketService).should().getOrCreateDraft(eventId, userId);
104112
then(seatService).should().reserveSeat(eventId, seatId, userId);
105-
then(ticketService).should().createDraftTicket(eventId, seatId, userId);
113+
then(seatService).should(never()).markSeatAsAvailable(any()); // 기존 좌석 없음
106114
}
107115

108116
@Test
@@ -116,15 +124,23 @@ void selectSeatAndCreateTicket_NotInQueue_ThrowsException() {
116124
.isInstanceOf(ErrorException.class)
117125
.hasFieldOrPropertyWithValue("errorCode", SeatErrorCode.NOT_IN_QUEUE);
118126

127+
then(ticketService).should(never()).getOrCreateDraft(any(), any());
119128
then(seatService).should(never()).reserveSeat(any(), any(), any());
120-
then(ticketService).should(never()).createDraftTicket(any(), any(), any());
121129
}
122130

123131
@Test
124-
@DisplayName("좌석 예약 실패 시 티켓 생성하지 않는다")
132+
@DisplayName("좌석 예약 실패 시 예외가 발생한다")
125133
void selectSeatAndCreateTicket_ReserveFail_DoesNotCreateTicket() {
126134
// given
135+
Ticket draftTicket = Ticket.builder()
136+
.owner(testUser)
137+
.event(testEvent)
138+
.seat(null)
139+
.ticketStatus(TicketStatus.DRAFT)
140+
.build();
141+
127142
given(queueEntryReadService.isUserEntered(eventId, userId)).willReturn(true);
143+
given(ticketService.getOrCreateDraft(eventId, userId)).willReturn(draftTicket);
128144
given(seatService.reserveSeat(eventId, seatId, userId))
129145
.willThrow(new ErrorException(SeatErrorCode.SEAT_ALREADY_RESERVED));
130146

@@ -133,7 +149,7 @@ void selectSeatAndCreateTicket_ReserveFail_DoesNotCreateTicket() {
133149
.isInstanceOf(ErrorException.class)
134150
.hasFieldOrPropertyWithValue("errorCode", SeatErrorCode.SEAT_ALREADY_RESERVED);
135151

136-
then(ticketService).should(never()).createDraftTicket(any(), any(), any());
152+
then(ticketService).should().getOrCreateDraft(eventId, userId);
137153
}
138154
}
139155

@@ -145,11 +161,18 @@ class SeatStatusValidationTest {
145161
@DisplayName("좌석 선택 성공 시 좌석이 RESERVED 상태가 된다")
146162
void selectSeat_SeatBecomesReserved() {
147163
// given
164+
Ticket draftTicket = Ticket.builder()
165+
.owner(testUser)
166+
.event(testEvent)
167+
.seat(null)
168+
.ticketStatus(TicketStatus.DRAFT)
169+
.build();
170+
148171
testSeat.markAsReserved(); // 예약 상태로 변경
149172

150173
given(queueEntryReadService.isUserEntered(eventId, userId)).willReturn(true);
174+
given(ticketService.getOrCreateDraft(eventId, userId)).willReturn(draftTicket);
151175
given(seatService.reserveSeat(eventId, seatId, userId)).willReturn(testSeat);
152-
given(ticketService.createDraftTicket(eventId, seatId, userId)).willReturn(testTicket);
153176

154177
// when
155178
Ticket result = seatSelectionService.selectSeatAndCreateTicket(eventId, seatId, userId);
@@ -164,22 +187,29 @@ void selectSeat_SeatBecomesReserved() {
164187
class TransactionRollbackTest {
165188

166189
@Test
167-
@DisplayName("티켓 생성 실패 시 예외가 발생한다")
190+
@DisplayName("좌석 할당 중 예외 발생 시 예외가 전파된다")
168191
void selectSeatAndCreateTicket_TicketCreationFail_ThrowsException() {
169192
// given
193+
Ticket draftTicket = Ticket.builder()
194+
.owner(testUser)
195+
.event(testEvent)
196+
.seat(null)
197+
.ticketStatus(TicketStatus.DRAFT)
198+
.build();
199+
170200
given(queueEntryReadService.isUserEntered(eventId, userId)).willReturn(true);
171-
given(seatService.reserveSeat(eventId, seatId, userId)).willReturn(testSeat);
172-
given(ticketService.createDraftTicket(eventId, seatId, userId))
173-
.willThrow(new RuntimeException("티켓 생성 실패"));
201+
given(ticketService.getOrCreateDraft(eventId, userId)).willReturn(draftTicket);
202+
given(seatService.reserveSeat(eventId, seatId, userId))
203+
.willThrow(new RuntimeException("좌석 예약 실패"));
174204

175205
// when & then
176206
assertThatThrownBy(() -> seatSelectionService.selectSeatAndCreateTicket(eventId, seatId, userId))
177207
.isInstanceOf(RuntimeException.class)
178-
.hasMessage("티켓 생성 실패");
208+
.hasMessage("좌석 예약 실패");
179209

180-
// 좌석 예약까지는 호출되었지만 트랜잭션 롤백으로 인해 실제로는 반영되지 않음
210+
// Draft Ticket은 조회되었지만, 좌석 예약 실패로 트랜잭션 롤백
211+
then(ticketService).should().getOrCreateDraft(eventId, userId);
181212
then(seatService).should().reserveSeat(eventId, seatId, userId);
182-
then(ticketService).should().createDraftTicket(eventId, seatId, userId);
183213
}
184214
}
185215
}

0 commit comments

Comments
 (0)