Skip to content

Commit 121c957

Browse files
authored
fix: 내 티켓 조회/발급 보정 (#566)
1 parent 8e05451 commit 121c957

5 files changed

Lines changed: 180 additions & 39 deletions

File tree

src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ public void completeReservation(Long reservationId) {
158158
Reservation reservation = getReservationWithLock(reservationId);
159159

160160
if (reservation.getStatus() == ReservationStatus.COMPLETED) {
161+
ticketService.ensureTicketsForReservation(reservationId);
161162
return;
162163
}
163164

@@ -169,6 +170,8 @@ public void completeReservation(Long reservationId) {
169170

170171
// 좌석 상태 변경 (HOLD → SOLD)
171172
reservationSeatManager.confirmAllSeats(reservationId);
173+
174+
ticketService.ensureTicketsForReservation(reservationId);
172175
}
173176

174177
// === 중복 예매 방지 === //

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@
44
import java.util.Optional;
55

66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
79

810
import com.back.b2st.domain.ticket.entity.Ticket;
911

1012
public interface TicketRepository extends JpaRepository<Ticket, Long> {
13+
interface MissingTicketKey {
14+
Long getReservationId();
15+
16+
Long getMemberId();
17+
18+
Long getSeatId();
19+
}
20+
1121
List<Ticket> findAllByReservationIdAndMemberId(Long reservationId, Long memberId);
1222

1323
Optional<Ticket> findByReservationIdAndMemberIdAndSeatId(Long reservationId, Long memberId, Long seatId);
@@ -18,5 +28,23 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
1828

1929
List<Ticket> findByReservationId(Long reservationId);
2030

31+
@Query("""
32+
select
33+
rs.reservationId as reservationId,
34+
r.memberId as memberId,
35+
ss.seatId as seatId
36+
from ReservationSeat rs
37+
join Reservation r on r.id = rs.reservationId
38+
join ScheduleSeat ss on ss.id = rs.scheduleSeatId
39+
left join Ticket t
40+
on t.reservationId = rs.reservationId
41+
and t.memberId = r.memberId
42+
and t.seatId = ss.seatId
43+
where r.memberId = :memberId
44+
and r.status = com.back.b2st.domain.reservation.entity.ReservationStatus.COMPLETED
45+
and t.id is null
46+
""")
47+
List<MissingTicketKey> findMissingTicketsForMember(@Param("memberId") Long memberId);
48+
2149
void deleteAllByReservationIdIn(List<Long> reservationIds);
2250
}

src/main/java/com/back/b2st/domain/ticket/service/TicketService.java

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package com.back.b2st.domain.ticket.service;
22

3+
import java.util.HashMap;
34
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
import java.util.Set;
8+
import java.util.function.Function;
49
import java.util.stream.Collectors;
510

611
import org.springframework.dao.DataIntegrityViolationException;
@@ -26,16 +31,15 @@
2631
import com.back.b2st.domain.ticket.error.TicketErrorCode;
2732
import com.back.b2st.domain.ticket.repository.TicketRepository;
2833
import com.back.b2st.domain.trade.entity.Trade;
34+
import com.back.b2st.domain.trade.entity.TradeType;
2935
import com.back.b2st.domain.trade.entity.TradeStatus;
3036
import com.back.b2st.domain.trade.repository.TradeRepository;
3137
import com.back.b2st.global.error.exception.BusinessException;
3238

3339
import lombok.RequiredArgsConstructor;
34-
import lombok.extern.slf4j.Slf4j;
3540

3641
@Service
3742
@RequiredArgsConstructor
38-
@Slf4j
3943
public class TicketService {
4044

4145
private final TicketRepository ticketRepository;
@@ -131,57 +135,101 @@ public Ticket restoreTicket(Long ticketId) {
131135
return ticket;
132136
}
133137

138+
@Transactional
139+
public void ensureTicketsForReservation(Long reservationId) {
140+
Reservation reservation = reservationRepository.findById(reservationId).orElse(null);
141+
if (reservation == null) {
142+
return;
143+
}
144+
145+
List<ReservationSeatInfo> seats = reservationSeatRepository.findSeatInfos(reservationId);
146+
if (seats.isEmpty()) {
147+
return;
148+
}
149+
150+
for (ReservationSeatInfo seat : seats) {
151+
createTicket(reservationId, reservation.getMemberId(), seat.seatId());
152+
}
153+
}
154+
155+
private void ensureTicketsForCompletedReservations(Long memberId) {
156+
List<TicketRepository.MissingTicketKey> missingTickets = ticketRepository.findMissingTicketsForMember(memberId);
157+
for (TicketRepository.MissingTicketKey key : missingTickets) {
158+
createTicket(key.getReservationId(), key.getMemberId(), key.getSeatId());
159+
}
160+
}
161+
162+
@Transactional
134163
public List<TicketRes> getMyTickets(Long memberId) {
164+
ensureTicketsForCompletedReservations(memberId);
165+
135166
List<Ticket> tickets = ticketRepository.findByMemberId(memberId);
136167

137168
// 구매자로 받은 완료된 거래 조회 (교환/양도)
138169
List<Trade> completedTrades = tradeRepository.findAllByBuyerIdAndStatus(memberId, TradeStatus.COMPLETED);
170+
Map<Long, AcquisitionType> acquisitionTypeBySeatId = computeAcquisitionTypeBySeatId(completedTrades);
139171

140172
return tickets.stream()
141-
.map(ticket -> {
142-
Seat seat = seatRepository.findById(ticket.getSeatId())
143-
.orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND));
144-
PerformanceSchedule schedule = resolveScheduleForTicket(ticket);
145-
146-
// 티켓 획득 경로 판단
147-
AcquisitionType acquisitionType = determineAcquisitionType(ticket, completedTrades);
148-
149-
return TicketRes.builder()
150-
.ticketId(ticket.getId())
151-
.reservationId(ticket.getReservationId())
152-
.seatId(ticket.getSeatId())
153-
.status(ticket.getStatus())
154-
.sectionName(seat.getSectionName())
155-
.rowLabel(seat.getRowLabel())
156-
.seatNumber(seat.getSeatNumber())
157-
.performanceId(schedule.getPerformance().getPerformanceId())
158-
.acquisitionType(acquisitionType)
159-
.build();
160-
})
173+
.map(ticket -> toTicketResOrNull(ticket, acquisitionTypeBySeatId))
174+
.filter(Objects::nonNull)
161175
.collect(Collectors.toList());
162176
}
163177

164-
/**
165-
* 티켓의 획득 경로를 판단합니다.
166-
* @param ticket 현재 티켓
167-
* @param completedTrades 구매자로 받은 완료된 거래 목록
168-
* @return AcquisitionType (RESERVATION, TRANSFER, EXCHANGE)
169-
*/
170-
private AcquisitionType determineAcquisitionType(Ticket ticket, List<Trade> completedTrades) {
171-
// 완료된 거래 중에서 현재 티켓의 좌석과 일치하는 거래 찾기
178+
private TicketRes toTicketResOrNull(Ticket ticket, Map<Long, AcquisitionType> acquisitionTypeBySeatId) {
179+
Seat seat = seatRepository.findById(ticket.getSeatId()).orElse(null);
180+
if (seat == null) {
181+
return null;
182+
}
183+
184+
PerformanceSchedule schedule;
185+
try {
186+
schedule = resolveScheduleForTicket(ticket);
187+
} catch (BusinessException e) {
188+
return null;
189+
}
190+
191+
AcquisitionType acquisitionType =
192+
acquisitionTypeBySeatId.getOrDefault(ticket.getSeatId(), AcquisitionType.RESERVATION);
193+
194+
return TicketRes.builder()
195+
.ticketId(ticket.getId())
196+
.reservationId(ticket.getReservationId())
197+
.seatId(ticket.getSeatId())
198+
.status(ticket.getStatus())
199+
.sectionName(seat.getSectionName())
200+
.rowLabel(seat.getRowLabel())
201+
.seatNumber(seat.getSeatNumber())
202+
.performanceId(schedule.getPerformance().getPerformanceId())
203+
.acquisitionType(acquisitionType)
204+
.build();
205+
}
206+
207+
private Map<Long, AcquisitionType> computeAcquisitionTypeBySeatId(List<Trade> completedTrades) {
208+
if (completedTrades.isEmpty()) {
209+
return Map.of();
210+
}
211+
212+
Set<Long> tradeTicketIds = completedTrades.stream()
213+
.map(Trade::getTicketId)
214+
.collect(Collectors.toSet());
215+
216+
Map<Long, Ticket> originalTicketsById = ticketRepository.findAllById(tradeTicketIds).stream()
217+
.collect(Collectors.toMap(Ticket::getId, Function.identity(), (a, b) -> a));
218+
219+
Map<Long, AcquisitionType> acquisitionTypeBySeatId = new HashMap<>();
172220
for (Trade trade : completedTrades) {
173-
// Trade의 원본 티켓 조회
174-
Ticket originalTicket = ticketRepository.findById(trade.getTicketId()).orElse(null);
175-
if (originalTicket != null && originalTicket.getSeatId().equals(ticket.getSeatId())) {
176-
// 같은 좌석이면 교환 또는 양도로 받은 티켓
177-
return trade.getType() == com.back.b2st.domain.trade.entity.TradeType.TRANSFER
178-
? AcquisitionType.TRANSFER
179-
: AcquisitionType.EXCHANGE;
221+
Ticket originalTicket = originalTicketsById.get(trade.getTicketId());
222+
if (originalTicket == null) {
223+
continue;
180224
}
225+
226+
acquisitionTypeBySeatId.putIfAbsent(
227+
originalTicket.getSeatId(),
228+
trade.getType() == TradeType.TRANSFER ? AcquisitionType.TRANSFER : AcquisitionType.EXCHANGE
229+
);
181230
}
182231

183-
// 거래 이력이 없으면 예매로 받은 티켓
184-
return AcquisitionType.RESERVATION;
232+
return acquisitionTypeBySeatId;
185233
}
186234

187235
private PerformanceSchedule resolveScheduleForTicket(Ticket ticket) {

src/test/java/com/back/b2st/domain/reservation/service/ReservationServiceTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,42 @@ void expireReservation_notExpired_noop() {
222222
verify(reservation, never()).expire();
223223
verify(reservationSeatManager, never()).releaseAllSeats(anyLong());
224224
}
225+
226+
@Test
227+
@DisplayName("completeReservation(): 예매 완료 시 좌석 확정 + 티켓 생성 보장")
228+
void completeReservation_success() {
229+
// given
230+
Reservation reservation = mock(Reservation.class);
231+
232+
when(reservationRepository.findByIdWithLock(RESERVATION_ID))
233+
.thenReturn(Optional.of(reservation));
234+
when(reservation.getStatus()).thenReturn(ReservationStatus.PENDING);
235+
236+
// when
237+
reservationService.completeReservation(RESERVATION_ID);
238+
239+
// then
240+
verify(reservation).complete(any(LocalDateTime.class));
241+
verify(reservationSeatManager).confirmAllSeats(RESERVATION_ID);
242+
verify(ticketService).ensureTicketsForReservation(RESERVATION_ID);
243+
}
244+
245+
@Test
246+
@DisplayName("completeReservation(): 이미 완료된 예매면 티켓만 보정하고 종료")
247+
void completeReservation_alreadyCompleted_ensuresTicketsOnly() {
248+
// given
249+
Reservation reservation = mock(Reservation.class);
250+
251+
when(reservationRepository.findByIdWithLock(RESERVATION_ID))
252+
.thenReturn(Optional.of(reservation));
253+
when(reservation.getStatus()).thenReturn(ReservationStatus.COMPLETED);
254+
255+
// when
256+
reservationService.completeReservation(RESERVATION_ID);
257+
258+
// then
259+
verify(ticketService).ensureTicketsForReservation(RESERVATION_ID);
260+
verify(reservationSeatManager, never()).confirmAllSeats(anyLong());
261+
verify(reservation, never()).complete(any(LocalDateTime.class));
262+
}
225263
}

src/test/java/com/back/b2st/domain/ticket/service/TicketServiceTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,30 @@ void createTicket_idempotency_returnExisting() {
566566
assertThat(count).isEqualTo(1L);
567567
}
568568

569+
@Test
570+
@DisplayName("내티켓조회_좌석이_없어도_전체_조회가_실패하지_않고_유효한_티켓만_반환")
571+
void getMyTickets_missingSeat_shouldSkipInvalidTickets() {
572+
// given
573+
Ticket invalidTicket = Ticket.builder()
574+
.reservationId(rId)
575+
.memberId(mId)
576+
.seatId(999_999L)
577+
.build();
578+
ticketRepository.save(invalidTicket);
579+
em.flush();
580+
em.clear();
581+
582+
// when
583+
List<TicketRes> tickets = ticketService.getMyTickets(mId);
584+
585+
// then
586+
assertThat(tickets).isNotEmpty();
587+
assertThat(tickets)
588+
.extracting(TicketRes::getTicketId)
589+
.contains(ticket.getId())
590+
.doesNotContain(invalidTicket.getId());
591+
}
592+
569593
@Test
570594
@DisplayName("내티켓조회_예매로받은티켓_RESERVATION")
571595
void getMyTickets_reservationTicket_shouldReturnReservationType() {

0 commit comments

Comments
 (0)