diff --git a/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java b/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java new file mode 100644 index 0000000..d5a1f08 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java @@ -0,0 +1,29 @@ +package com.festivalapp.api; + +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.service.PerformanceService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/performances") +@RequiredArgsConstructor +public class PerformanceController { + + private final PerformanceService performanceService; + + @GetMapping + ResponseEntity> getPerformances() { + return ResponseEntity.ok(performanceService.getPerformances()); + } + + @GetMapping("/{performanceId}") + ResponseEntity getPerformance(@PathVariable Long performanceId) { + return ResponseEntity.ok(performanceService.getPerformance(performanceId)); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/api/TicketReservationController.java b/festival-app/backend/src/main/java/com/festivalapp/api/TicketReservationController.java new file mode 100644 index 0000000..c43ddfa --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/api/TicketReservationController.java @@ -0,0 +1,57 @@ +package com.festivalapp.api; + +import com.festivalapp.dto.TicketReservationCreateRequest; +import com.festivalapp.dto.TicketReservationResponse; +import com.festivalapp.security.AuthenticatedUser; +import com.festivalapp.service.TicketReservationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/ticket-reservations") +@RequiredArgsConstructor +public class TicketReservationController { + + private final TicketReservationService ticketReservationService; + + @PostMapping + ResponseEntity reserveTicket( + Authentication authentication, + @RequestBody TicketReservationCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ticketReservationService.reserveTicket(authenticatedUserId(authentication), request)); + } + + @GetMapping("/me") + ResponseEntity> getMyReservations(Authentication authentication) { + return ResponseEntity.ok( + ticketReservationService.getMyReservations(authenticatedUserId(authentication))); + } + + @GetMapping("/{reservationId}") + ResponseEntity getMyReservation( + Authentication authentication, + @PathVariable String reservationId) { + return ResponseEntity.ok( + ticketReservationService.getMyReservation(authenticatedUserId(authentication), reservationId)); + } + + private String authenticatedUserId(Authentication authentication) { + if (authentication == null + || !(authentication.getPrincipal() instanceof AuthenticatedUser user)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + return user.id(); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java index 41d15d0..4e9fe2e 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java +++ b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java @@ -50,6 +50,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/booths/**", "/api/timeline", "/api/timeline/**", + "/api/performances", + "/api/performances/**", "/api/map-locations", "/api/map-locations/**") .permitAll() diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java b/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java new file mode 100644 index 0000000..f759f94 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java @@ -0,0 +1,13 @@ +package com.festivalapp.domain.performance; + +import java.time.LocalDateTime; + +public record Performance( + Long id, + String title, + String artist, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String description, + int totalSeats) {} diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservation.java b/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservation.java new file mode 100644 index 0000000..b74ee1a --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservation.java @@ -0,0 +1,79 @@ +package com.festivalapp.domain.ticket; + +import java.time.LocalDateTime; + +public class TicketReservation { + + private final String id; + private final Long performanceId; + private final String userId; + private TicketReservationStatus status; + private String qrCode; + private final LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public TicketReservation( + String id, + Long performanceId, + String userId, + TicketReservationStatus status, + String qrCode, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = id; + this.performanceId = performanceId; + this.userId = userId; + this.status = status; + this.qrCode = qrCode; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public void markReservationCreated(LocalDateTime now) { + status = TicketReservationStatus.RESERVATION_CREATED; + updatedAt = now; + } + + public void issueQr(String issuedQrCode, LocalDateTime now) { + status = TicketReservationStatus.QR_ISSUED; + qrCode = issuedQrCode; + updatedAt = now; + } + + public void complete(LocalDateTime now) { + status = TicketReservationStatus.COMPLETED; + updatedAt = now; + } + + public boolean isSeatOccupying() { + return status != TicketReservationStatus.CANCELLED; + } + + public String id() { + return id; + } + + public Long performanceId() { + return performanceId; + } + + public String userId() { + return userId; + } + + public TicketReservationStatus status() { + return status; + } + + public String qrCode() { + return qrCode; + } + + public LocalDateTime createdAt() { + return createdAt; + } + + public LocalDateTime updatedAt() { + return updatedAt; + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservationStatus.java b/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservationStatus.java new file mode 100644 index 0000000..beabbc0 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/ticket/TicketReservationStatus.java @@ -0,0 +1,16 @@ +package com.festivalapp.domain.ticket; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TicketReservationStatus { + SEAT_HELD("좌석 선점"), + RESERVATION_CREATED("예매 생성"), + QR_ISSUED("QR 티켓 발급"), + COMPLETED("예매 완료"), + CANCELLED("예매 취소"); + + private final String description; +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java new file mode 100644 index 0000000..c164eb8 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java @@ -0,0 +1,29 @@ +package com.festivalapp.dto; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; + +public record PerformanceResponse( + Long id, + String title, + String artist, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String description, + int totalSeats, + int remainingSeats) { + + public static PerformanceResponse from(Performance performance, int remainingSeats) { + return new PerformanceResponse( + performance.id(), + performance.title(), + performance.artist(), + performance.startsAt(), + performance.endsAt(), + performance.location(), + performance.description(), + performance.totalSeats(), + remainingSeats); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationCreateRequest.java b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationCreateRequest.java new file mode 100644 index 0000000..a998a58 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationCreateRequest.java @@ -0,0 +1,3 @@ +package com.festivalapp.dto; + +public record TicketReservationCreateRequest(Long performanceId) {} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationResponse.java new file mode 100644 index 0000000..ab6a18d --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationResponse.java @@ -0,0 +1,32 @@ +package com.festivalapp.dto; + +import com.festivalapp.domain.ticket.TicketReservation; +import java.time.LocalDateTime; +import java.util.List; + +public record TicketReservationResponse( + String id, + Long performanceId, + String userId, + String status, + String statusDescription, + String qrCode, + List sagaLogs, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static TicketReservationResponse from( + TicketReservation reservation, + List sagaLogs) { + return new TicketReservationResponse( + reservation.id(), + reservation.performanceId(), + reservation.userId(), + reservation.status().name(), + reservation.status().getDescription(), + reservation.qrCode(), + sagaLogs, + reservation.createdAt(), + reservation.updatedAt()); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationSagaLogResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationSagaLogResponse.java new file mode 100644 index 0000000..29ee215 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/TicketReservationSagaLogResponse.java @@ -0,0 +1,8 @@ +package com.festivalapp.dto; + +import java.time.LocalDateTime; + +public record TicketReservationSagaLogResponse( + String step, + String message, + LocalDateTime createdAt) {} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java new file mode 100644 index 0000000..74d3675 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java @@ -0,0 +1,28 @@ +package com.festivalapp.repository.performance; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.repository.performance.datasource.PerformanceDataSource; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PerformanceRepository { + + private final PerformanceDataSource performanceDataSource; + + public List findAll() { + return performanceDataSource.findAll().stream() + .sorted(Comparator.comparing(Performance::startsAt)) + .toList(); + } + + public Optional findById(Long performanceId) { + return performanceDataSource.findAll().stream() + .filter(performance -> performance.id().equals(performanceId)) + .findFirst(); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java new file mode 100644 index 0000000..a4c63b2 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java @@ -0,0 +1,85 @@ +package com.festivalapp.repository.performance.datasource; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class InMemoryPerformanceDataSource implements PerformanceDataSource { + + private final List performances = + List.of( + performance( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + 2026, + 5, + 13, + 19, + 0, + 21, + 0, + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + 500), + performance( + 2L, + "동아리 밴드 쇼케이스", + "학생 밴드 연합", + 2026, + 5, + 14, + 18, + 0, + 19, + 30, + "학생회관 야외무대", + "교내 밴드 동아리들이 준비한 라이브 쇼케이스입니다.", + 220), + performance( + 3L, + "WOW DJ Festival", + "WOW DJ Crew", + 2026, + 5, + 15, + 20, + 0, + 23, + 30, + "운동장 DJ 스테이지", + "축제 마지막 밤을 채우는 DJ 페스티벌 공연입니다.", + 800)); + + @Override + public List findAll() { + return performances; + } + + private static Performance performance( + Long id, + String title, + String artist, + int year, + int month, + int day, + int startHour, + int startMinute, + int endHour, + int endMinute, + String location, + String description, + int totalSeats) { + return new Performance( + id, + title, + artist, + LocalDateTime.of(year, month, day, startHour, startMinute), + LocalDateTime.of(year, month, day, endHour, endMinute), + location, + description, + totalSeats); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java new file mode 100644 index 0000000..e2b436c --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java @@ -0,0 +1,9 @@ +package com.festivalapp.repository.performance.datasource; + +import com.festivalapp.domain.performance.Performance; +import java.util.List; + +public interface PerformanceDataSource { + + List findAll(); +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/TicketReservationRepository.java b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/TicketReservationRepository.java new file mode 100644 index 0000000..fe91cf4 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/TicketReservationRepository.java @@ -0,0 +1,52 @@ +package com.festivalapp.repository.ticket; + +import com.festivalapp.domain.ticket.TicketReservation; +import com.festivalapp.repository.ticket.datasource.TicketReservationDataSource; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TicketReservationRepository { + + private final TicketReservationDataSource ticketReservationDataSource; + + public List findAll() { + return ticketReservationDataSource.findAll(); + } + + public Optional findById(String reservationId) { + return ticketReservationDataSource.findById(reservationId); + } + + public List findByUserId(String userId) { + return findAll().stream() + .filter(reservation -> reservation.userId().equals(userId)) + .toList(); + } + + public int countReservedSeatsByPerformanceId(Long performanceId) { + return (int) findAll().stream() + .filter(reservation -> reservation.performanceId().equals(performanceId)) + .filter(TicketReservation::isSeatOccupying) + .count(); + } + + public boolean existsActiveByPerformanceIdAndUserId(Long performanceId, String userId) { + return findAll().stream() + .anyMatch(reservation -> + reservation.performanceId().equals(performanceId) + && reservation.userId().equals(userId) + && reservation.isSeatOccupying()); + } + + public TicketReservation save(TicketReservation reservation) { + return ticketReservationDataSource.save(reservation); + } + + public TicketReservation update(TicketReservation reservation) { + return ticketReservationDataSource.update(reservation); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/InMemoryTicketReservationDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/InMemoryTicketReservationDataSource.java new file mode 100644 index 0000000..937bd5d --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/InMemoryTicketReservationDataSource.java @@ -0,0 +1,36 @@ +package com.festivalapp.repository.ticket.datasource; + +import com.festivalapp.domain.ticket.TicketReservation; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class InMemoryTicketReservationDataSource implements TicketReservationDataSource { + + private final List reservations = new ArrayList<>(); + + @Override + public synchronized List findAll() { + return List.copyOf(reservations); + } + + @Override + public synchronized Optional findById(String reservationId) { + return reservations.stream() + .filter(reservation -> reservation.id().equals(reservationId)) + .findFirst(); + } + + @Override + public synchronized TicketReservation save(TicketReservation reservation) { + reservations.add(reservation); + return reservation; + } + + @Override + public synchronized TicketReservation update(TicketReservation reservation) { + return reservation; + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/TicketReservationDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/TicketReservationDataSource.java new file mode 100644 index 0000000..f03418d --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/ticket/datasource/TicketReservationDataSource.java @@ -0,0 +1,16 @@ +package com.festivalapp.repository.ticket.datasource; + +import com.festivalapp.domain.ticket.TicketReservation; +import java.util.List; +import java.util.Optional; + +public interface TicketReservationDataSource { + + List findAll(); + + Optional findById(String reservationId); + + TicketReservation save(TicketReservation reservation); + + TicketReservation update(TicketReservation reservation); +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java b/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java new file mode 100644 index 0000000..fdc35df --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java @@ -0,0 +1,37 @@ +package com.festivalapp.service; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import com.festivalapp.repository.ticket.TicketReservationRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +public class PerformanceService { + + private final PerformanceRepository performanceRepository; + private final TicketReservationRepository ticketReservationRepository; + + public List getPerformances() { + return performanceRepository.findAll().stream() + .map(this::toResponse) + .toList(); + } + + public PerformanceResponse getPerformance(Long performanceId) { + return performanceRepository.findById(performanceId) + .map(this::toResponse) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "공연을 찾을 수 없습니다.")); + } + + private PerformanceResponse toResponse(Performance performance) { + int reservedSeats = + ticketReservationRepository.countReservedSeatsByPerformanceId(performance.id()); + return PerformanceResponse.from(performance, Math.max(performance.totalSeats() - reservedSeats, 0)); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/TicketReservationService.java b/festival-app/backend/src/main/java/com/festivalapp/service/TicketReservationService.java new file mode 100644 index 0000000..ee454de --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/TicketReservationService.java @@ -0,0 +1,111 @@ +package com.festivalapp.service; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.domain.ticket.TicketReservation; +import com.festivalapp.domain.ticket.TicketReservationStatus; +import com.festivalapp.dto.TicketReservationCreateRequest; +import com.festivalapp.dto.TicketReservationResponse; +import com.festivalapp.dto.TicketReservationSagaLogResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import com.festivalapp.repository.ticket.TicketReservationRepository; +import com.festivalapp.service.ticket.TicketQrCodeIssuer; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +public class TicketReservationService { + + private final PerformanceRepository performanceRepository; + private final TicketReservationRepository ticketReservationRepository; + private final TicketQrCodeIssuer ticketQrCodeIssuer; + + @Transactional + public TicketReservationResponse reserveTicket( + String userId, + TicketReservationCreateRequest request) { + if (request == null || request.performanceId() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예매할 공연 정보가 필요합니다."); + } + + Performance performance = performanceRepository.findById(request.performanceId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "공연을 찾을 수 없습니다.")); + List sagaLogs = new ArrayList<>(); + + LocalDateTime checkedAt = LocalDateTime.now(); + validateRemainingSeat(performance); + sagaLogs.add(log("SEAT_CHECKED", "잔여 좌석을 확인했습니다.", checkedAt)); + + if (ticketReservationRepository.existsActiveByPerformanceIdAndUserId(performance.id(), userId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 예매한 공연입니다."); + } + + LocalDateTime heldAt = LocalDateTime.now(); + TicketReservation reservation = + ticketReservationRepository.save(new TicketReservation( + UUID.randomUUID().toString(), + performance.id(), + userId, + TicketReservationStatus.SEAT_HELD, + null, + heldAt, + heldAt)); + sagaLogs.add(log("SEAT_HELD", "좌석을 선점했습니다.", heldAt)); + + LocalDateTime createdAt = LocalDateTime.now(); + reservation.markReservationCreated(createdAt); + ticketReservationRepository.update(reservation); + sagaLogs.add(log("RESERVATION_CREATED", "예매 정보를 생성했습니다.", createdAt)); + + LocalDateTime issuedAt = LocalDateTime.now(); + reservation.issueQr(ticketQrCodeIssuer.issue(reservation), issuedAt); + ticketReservationRepository.update(reservation); + sagaLogs.add(log("QR_ISSUED", "QR 티켓을 발급했습니다.", issuedAt)); + + LocalDateTime completedAt = LocalDateTime.now(); + reservation.complete(completedAt); + TicketReservation savedReservation = ticketReservationRepository.update(reservation); + sagaLogs.add(log("COMPLETED", "예매를 완료했습니다.", completedAt)); + + return TicketReservationResponse.from(savedReservation, sagaLogs); + } + + public List getMyReservations(String userId) { + return ticketReservationRepository.findByUserId(userId).stream() + .map(reservation -> TicketReservationResponse.from(reservation, List.of())) + .toList(); + } + + public TicketReservationResponse getMyReservation(String userId, String reservationId) { + TicketReservation reservation = ticketReservationRepository.findById(reservationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "예매 정보를 찾을 수 없습니다.")); + + if (!reservation.userId().equals(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인의 예매 정보만 확인할 수 있습니다."); + } + + return TicketReservationResponse.from(reservation, List.of()); + } + + private void validateRemainingSeat(Performance performance) { + int reservedSeats = ticketReservationRepository.countReservedSeatsByPerformanceId(performance.id()); + + if (reservedSeats >= performance.totalSeats()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "잔여 좌석이 없습니다."); + } + } + + private TicketReservationSagaLogResponse log( + String step, + String message, + LocalDateTime createdAt) { + return new TicketReservationSagaLogResponse(step, message, createdAt); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/ticket/TicketQrCodeIssuer.java b/festival-app/backend/src/main/java/com/festivalapp/service/ticket/TicketQrCodeIssuer.java new file mode 100644 index 0000000..0d61c92 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/ticket/TicketQrCodeIssuer.java @@ -0,0 +1,20 @@ +package com.festivalapp.service.ticket; + +import com.festivalapp.domain.ticket.TicketReservation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class TicketQrCodeIssuer { + + private final String frontendBaseUrl; + + public TicketQrCodeIssuer( + @Value("${app.frontend-base-url:http://localhost:3000}") String frontendBaseUrl) { + this.frontendBaseUrl = frontendBaseUrl; + } + + public String issue(TicketReservation reservation) { + return frontendBaseUrl + "/performances/tickets/" + reservation.id(); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/TicketReservationControllerTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/TicketReservationControllerTest.java new file mode 100644 index 0000000..4720a83 --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/api/TicketReservationControllerTest.java @@ -0,0 +1,110 @@ +package com.festivalapp.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festivalapp.domain.auth.UserRole; +import com.festivalapp.dto.TicketReservationCreateRequest; +import com.festivalapp.dto.TicketReservationResponse; +import com.festivalapp.dto.TicketReservationSagaLogResponse; +import com.festivalapp.security.AuthenticatedUser; +import com.festivalapp.service.TicketReservationService; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class TicketReservationControllerTest { + + private final TicketReservationService ticketReservationService = mock(TicketReservationService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .standaloneSetup(new TicketReservationController(ticketReservationService)) + .build(); + } + + @Test + void reserveTicketReturnsCreatedCompletedReservation() throws Exception { + given(ticketReservationService.reserveTicket( + eq("user-1"), + any(TicketReservationCreateRequest.class))) + .willReturn(ticketReservationResponse()); + + mockMvc.perform(post("/api/ticket-reservations") + .principal(authenticatedUser("user-1")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "performanceId": 1 + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("COMPLETED")) + .andExpect(jsonPath("$.qrCode") + .value("http://localhost:3000/performances/tickets/ticket-1")) + .andExpect(jsonPath("$.sagaLogs[0].step").value("SEAT_CHECKED")) + .andExpect(jsonPath("$.sagaLogs[4].step").value("COMPLETED")); + } + + @Test + void reserveTicketRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/ticket-reservations") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "performanceId": 1 + } + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getMyReservationReturnsReservation() throws Exception { + given(ticketReservationService.getMyReservation("user-1", "ticket-1")) + .willReturn(ticketReservationResponse()); + + mockMvc.perform(get("/api/ticket-reservations/ticket-1") + .principal(authenticatedUser("user-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("ticket-1")); + } + + private TicketReservationResponse ticketReservationResponse() { + LocalDateTime now = LocalDateTime.of(2026, 5, 13, 19, 0); + return new TicketReservationResponse( + "ticket-1", + 1L, + "user-1", + "COMPLETED", + "예매 완료", + "http://localhost:3000/performances/tickets/ticket-1", + List.of( + new TicketReservationSagaLogResponse("SEAT_CHECKED", "잔여 좌석을 확인했습니다.", now), + new TicketReservationSagaLogResponse("SEAT_HELD", "좌석을 선점했습니다.", now), + new TicketReservationSagaLogResponse("RESERVATION_CREATED", "예매 정보를 생성했습니다.", now), + new TicketReservationSagaLogResponse("QR_ISSUED", "QR 티켓을 발급했습니다.", now), + new TicketReservationSagaLogResponse("COMPLETED", "예매를 완료했습니다.", now)), + now, + now); + } + + private Authentication authenticatedUser(String userId) { + return new TestingAuthenticationToken( + new AuthenticatedUser(userId, userId + "@hongik.ac.kr", UserRole.USER), + null); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java b/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java new file mode 100644 index 0000000..75e7bd2 --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java @@ -0,0 +1,47 @@ +package com.festivalapp.repository.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PerformanceRepositoryTest { + + @Test + void findAllReturnsPerformancesSortedByStartTime() { + Performance latePerformance = + performance(2L, "늦은 공연", LocalDateTime.of(2026, 5, 14, 20, 0)); + Performance earlyPerformance = + performance(1L, "이른 공연", LocalDateTime.of(2026, 5, 14, 18, 0)); + PerformanceRepository performanceRepository = + new PerformanceRepository(() -> List.of(latePerformance, earlyPerformance)); + + List performances = performanceRepository.findAll(); + + assertThat(performances).containsExactly(earlyPerformance, latePerformance); + } + + @Test + void findByIdReturnsMatchingPerformance() { + Performance performance = + performance(1L, "와우 스테이지 헤드라이너", LocalDateTime.of(2026, 5, 13, 19, 0)); + PerformanceRepository performanceRepository = new PerformanceRepository(() -> List.of(performance)); + + assertThat(performanceRepository.findById(1L)).contains(performance); + assertThat(performanceRepository.findById(999L)).isEmpty(); + } + + private Performance performance(Long id, String title, LocalDateTime startsAt) { + return new Performance( + id, + title, + "아티스트", + startsAt, + startsAt.plusHours(2), + "메인무대", + "공연 설명입니다.", + 100); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/repository/ticket/TestTicketReservationDataSource.java b/festival-app/backend/src/test/java/com/festivalapp/repository/ticket/TestTicketReservationDataSource.java new file mode 100644 index 0000000..9ace5d1 --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/repository/ticket/TestTicketReservationDataSource.java @@ -0,0 +1,38 @@ +package com.festivalapp.repository.ticket; + +import com.festivalapp.domain.ticket.TicketReservation; +import com.festivalapp.repository.ticket.datasource.TicketReservationDataSource; +import java.util.List; +import java.util.Optional; + +public class TestTicketReservationDataSource implements TicketReservationDataSource { + + private final List reservations; + + public TestTicketReservationDataSource(List reservations) { + this.reservations = reservations; + } + + @Override + public List findAll() { + return reservations; + } + + @Override + public Optional findById(String reservationId) { + return reservations.stream() + .filter(reservation -> reservation.id().equals(reservationId)) + .findFirst(); + } + + @Override + public TicketReservation save(TicketReservation reservation) { + reservations.add(reservation); + return reservation; + } + + @Override + public TicketReservation update(TicketReservation reservation) { + return reservation; + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java new file mode 100644 index 0000000..148ff0c --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java @@ -0,0 +1,54 @@ +package com.festivalapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.domain.ticket.TicketReservation; +import com.festivalapp.domain.ticket.TicketReservationStatus; +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import com.festivalapp.repository.ticket.TestTicketReservationDataSource; +import com.festivalapp.repository.ticket.TicketReservationRepository; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PerformanceServiceTest { + + @Test + void getPerformanceReturnsRemainingSeats() { + Performance performance = + new Performance( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + LocalDateTime.of(2026, 5, 13, 19, 0), + LocalDateTime.of(2026, 5, 13, 21, 0), + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + 2); + TicketReservationRepository ticketReservationRepository = + new TicketReservationRepository( + new TestTicketReservationDataSource( + new ArrayList<>(List.of(ticketReservation("ticket-1", 1L))))); + PerformanceService performanceService = + new PerformanceService(new PerformanceRepository(() -> List.of(performance)), ticketReservationRepository); + + PerformanceResponse response = performanceService.getPerformance(1L); + + assertThat(response.remainingSeats()).isEqualTo(1); + } + + private TicketReservation ticketReservation(String id, Long performanceId) { + LocalDateTime now = LocalDateTime.of(2026, 5, 13, 18, 0); + return new TicketReservation( + id, + performanceId, + "user-1", + TicketReservationStatus.COMPLETED, + "http://localhost:3000/performances/tickets/" + id, + now, + now); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/TicketReservationServiceTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/TicketReservationServiceTest.java new file mode 100644 index 0000000..af26e5b --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/service/TicketReservationServiceTest.java @@ -0,0 +1,105 @@ +package com.festivalapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.domain.ticket.TicketReservation; +import com.festivalapp.domain.ticket.TicketReservationStatus; +import com.festivalapp.dto.TicketReservationCreateRequest; +import com.festivalapp.dto.TicketReservationResponse; +import com.festivalapp.dto.TicketReservationSagaLogResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import com.festivalapp.repository.ticket.TestTicketReservationDataSource; +import com.festivalapp.repository.ticket.TicketReservationRepository; +import com.festivalapp.service.ticket.TicketQrCodeIssuer; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +class TicketReservationServiceTest { + + @Test + void reserveTicketCompletesNormalSagaAndIssuesQrTicket() { + List reservations = new ArrayList<>(); + TicketReservationService ticketReservationService = serviceWith(reservations, performance(2)); + + TicketReservationResponse response = + ticketReservationService.reserveTicket("user-1", new TicketReservationCreateRequest(1L)); + + assertThat(response.status()).isEqualTo("COMPLETED"); + assertThat(response.qrCode()).contains("/performances/tickets/"); + assertThat(response.sagaLogs()) + .extracting(TicketReservationSagaLogResponse::step) + .containsExactly( + "SEAT_CHECKED", + "SEAT_HELD", + "RESERVATION_CREATED", + "QR_ISSUED", + "COMPLETED"); + assertThat(reservations).hasSize(1); + assertThat(reservations.get(0).status()).isEqualTo(TicketReservationStatus.COMPLETED); + } + + @Test + void reserveTicketRejectsWhenNoRemainingSeats() { + List reservations = + new ArrayList<>(List.of(ticketReservation("ticket-1", "user-1", 1L))); + TicketReservationService ticketReservationService = serviceWith(reservations, performance(1)); + + assertThatThrownBy( + () -> ticketReservationService.reserveTicket("user-2", new TicketReservationCreateRequest(1L))) + .isInstanceOf(ResponseStatusException.class) + .extracting("statusCode") + .isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void reserveTicketRejectsDuplicateActiveReservation() { + List reservations = + new ArrayList<>(List.of(ticketReservation("ticket-1", "user-1", 1L))); + TicketReservationService ticketReservationService = serviceWith(reservations, performance(2)); + + assertThatThrownBy( + () -> ticketReservationService.reserveTicket("user-1", new TicketReservationCreateRequest(1L))) + .isInstanceOf(ResponseStatusException.class) + .extracting("statusCode") + .isEqualTo(HttpStatus.CONFLICT); + } + + private TicketReservationService serviceWith( + List reservations, + Performance performance) { + return new TicketReservationService( + new PerformanceRepository(() -> List.of(performance)), + new TicketReservationRepository(new TestTicketReservationDataSource(reservations)), + new TicketQrCodeIssuer("http://localhost:3000")); + } + + private Performance performance(int totalSeats) { + return new Performance( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + LocalDateTime.of(2026, 5, 13, 19, 0), + LocalDateTime.of(2026, 5, 13, 21, 0), + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + totalSeats); + } + + private TicketReservation ticketReservation(String id, String userId, Long performanceId) { + LocalDateTime now = LocalDateTime.of(2026, 5, 13, 18, 0); + return new TicketReservation( + id, + performanceId, + userId, + TicketReservationStatus.COMPLETED, + "http://localhost:3000/performances/tickets/" + id, + now, + now); + } +} diff --git a/festival-app/frontend/src/apis/performances/performance.api.ts b/festival-app/frontend/src/apis/performances/performance.api.ts new file mode 100644 index 0000000..e3d1b99 --- /dev/null +++ b/festival-app/frontend/src/apis/performances/performance.api.ts @@ -0,0 +1,14 @@ +import type { + GetPerformanceParams, + GetPerformancesParams, +} from "@/apis/performances/performance.api.types"; +import { apiClient } from "@/libs/api/api-client"; +import type { Performance } from "@/types/performance/performance.types"; + +export function getPerformances({ signal }: GetPerformancesParams = {}) { + return apiClient.get("api/performances", { signal }).json(); +} + +export function getPerformance({ id, signal }: GetPerformanceParams) { + return apiClient.get(`api/performances/${id}`, { signal }).json(); +} diff --git a/festival-app/frontend/src/apis/performances/performance.api.types.ts b/festival-app/frontend/src/apis/performances/performance.api.types.ts new file mode 100644 index 0000000..6fd1c17 --- /dev/null +++ b/festival-app/frontend/src/apis/performances/performance.api.types.ts @@ -0,0 +1,8 @@ +export type GetPerformanceParams = { + id: number; + signal?: AbortSignal; +}; + +export type GetPerformancesParams = { + signal?: AbortSignal; +}; diff --git a/festival-app/frontend/src/apis/tickets/ticket.api.ts b/festival-app/frontend/src/apis/tickets/ticket.api.ts new file mode 100644 index 0000000..a4a49ac --- /dev/null +++ b/festival-app/frontend/src/apis/tickets/ticket.api.ts @@ -0,0 +1,15 @@ +import { apiClient } from "@/libs/api/api-client"; +import type { + TicketReservation, + TicketReservationCreateRequest, +} from "@/types/ticket/ticket.types"; + +export function createTicketReservation(request: TicketReservationCreateRequest) { + return apiClient.post("api/ticket-reservations", { json: request }).json(); +} + +export function getTicketReservation(reservationId: string, signal?: AbortSignal) { + return apiClient + .get(`api/ticket-reservations/${reservationId}`, { signal }) + .json(); +} diff --git a/festival-app/frontend/src/app/performances/[performanceId]/page.tsx b/festival-app/frontend/src/app/performances/[performanceId]/page.tsx new file mode 100644 index 0000000..3abc2dd --- /dev/null +++ b/festival-app/frontend/src/app/performances/[performanceId]/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; + +import { getPerformance } from "@/apis/performances/performance.api"; +import { createTicketReservation } from "@/apis/tickets/ticket.api"; +import PerformanceDetailPanel from "@/components/performances/PerformanceDetailPanel"; +import PerformanceReservationPanel from "@/components/performances/PerformanceReservationPanel"; +import SiteHeader from "@/components/SiteHeader"; +import { useAuth } from "@/providers/AuthProvider"; +import type { Performance } from "@/types/performance/performance.types"; +import type { TicketReservation } from "@/types/ticket/ticket.types"; +import { getAuthErrorMessage } from "@/utils/auth-error"; + +export default function PerformanceDetailPage() { + const params = useParams<{ performanceId: string }>(); + const { isAuthenticated, isInitialized } = useAuth(); + const performanceId = useMemo(() => Number(params.performanceId), [params.performanceId]); + const [reservation, setReservation] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { + data: performance, + error, + isError, + isLoading, + } = useQuery({ + queryKey: ["performance", performanceId], + queryFn: ({ signal }) => getPerformance({ id: performanceId, signal }), + enabled: Number.isFinite(performanceId), + }); + + async function handleReserve() { + setErrorMessage(""); + setIsSubmitting(true); + + try { + setReservation(await createTicketReservation({ performanceId })); + } catch (error) { + setErrorMessage(await getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + +
+ + 공연 목록으로 돌아가기 + + +
+ {isLoading && ( + <> +
+
+ + )} + + {!isLoading && isError && ( +
+ {error instanceof Error ? error.message : "공연 상세 정보를 불러오지 못했습니다."} +
+ )} + + {!isLoading && !isError && performance && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/festival-app/frontend/src/app/performances/page.tsx b/festival-app/frontend/src/app/performances/page.tsx index db4835e..603e20f 100644 --- a/festival-app/frontend/src/app/performances/page.tsx +++ b/festival-app/frontend/src/app/performances/page.tsx @@ -1,29 +1,24 @@ "use client"; -import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { getPerformances } from "@/apis/performances/performance.api"; +import PerformanceCard from "@/components/performances/PerformanceCard"; import SiteHeader from "@/components/SiteHeader"; import { useAuth } from "@/providers/AuthProvider"; - -const performances = [ - { - id: "performance-1", - title: "와우 스테이지 헤드라이너", - time: "2026.05.13 19:00", - location: "대운동장 메인 스테이지", - remainingSeats: 128, - }, - { - id: "performance-2", - title: "동아리 밴드 쇼케이스", - time: "2026.05.14 18:00", - location: "학생회관 야외무대", - remainingSeats: 64, - }, -]; +import type { Performance } from "@/types/performance/performance.types"; export default function PerformancesPage() { const { isAuthenticated, isInitialized, user } = useAuth(); + const { + data: performances = [], + error, + isError, + isLoading, + } = useQuery({ + queryKey: ["performances"], + queryFn: ({ signal }) => getPerformances({ signal }), + }); return (
@@ -34,57 +29,54 @@ export default function PerformancesPage() {

PERFORMANCE TICKETS

공연 예매

- 로그인한 사용자만 공연 예매를 진행할 수 있습니다. + 공연을 선택하고 좌석 선점부터 QR 티켓 발급까지 한 번에 진행하세요.

- {!isInitialized ? ( -

- 로그인 상태를 확인하는 중입니다. -

- ) : !isAuthenticated ? ( -
-

로그인이 필요합니다

-

- 공연 좌석 선점과 QR 티켓 발급은 계정 확인 후 진행됩니다. -

- - 로그인하고 예매하기 - -
- ) : ( -
-

- {user?.name}님, 예매 가능한 공연입니다. +

+
+
+

공연 목록

+

+ {isInitialized && isAuthenticated && user + ? `${user.name}님, 예매 가능한 공연입니다.` + : "로그인 후 공연 티켓 예매를 진행할 수 있습니다."} +

+
+ {performances.length}개 공연 +
+ + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ )} + + {!isLoading && isError && ( +
+ {error instanceof Error ? error.message : "공연 정보를 불러오지 못했습니다."} +
+ )} + + {!isLoading && !isError && performances.length === 0 && ( +

+ 등록된 공연 정보가 없습니다.

+ )} + + {!isLoading && !isError && performances.length > 0 && (
{performances.map((performance) => ( -
-

{performance.title}

-

{performance.time}

-

{performance.location}

-
- - 잔여 {performance.remainingSeats}석 - - -
-
+ ))}
-
- )} + )} +
); diff --git a/festival-app/frontend/src/app/performances/tickets/[reservationId]/page.tsx b/festival-app/frontend/src/app/performances/tickets/[reservationId]/page.tsx new file mode 100644 index 0000000..f708ee9 --- /dev/null +++ b/festival-app/frontend/src/app/performances/tickets/[reservationId]/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; + +import { getTicketReservation } from "@/apis/tickets/ticket.api"; +import TicketQr from "@/components/performances/TicketQr"; +import SiteHeader from "@/components/SiteHeader"; +import type { TicketReservation } from "@/types/ticket/ticket.types"; + +export default function PerformanceTicketPage() { + const params = useParams<{ reservationId: string }>(); + const { + data: reservation, + error, + isError, + isLoading, + } = useQuery({ + queryKey: ["ticket-reservation", params.reservationId], + queryFn: ({ signal }) => getTicketReservation(params.reservationId, signal), + }); + + return ( +
+ +
+
+

Performance Ticket

+

공연 티켓 확인

+ + {isLoading ? ( +

티켓 정보를 불러오는 중입니다.

+ ) : null} + + {isError ? ( +

+ {error instanceof Error ? error.message : "티켓 정보를 확인하지 못했습니다."} +

+ ) : null} + + {reservation ? ( +
+
+
+

예매 번호

+

{reservation.id}

+
+ + {reservation.statusDescription} + +
+ +
+
+
공연
+
{reservation.performanceId}
+
+
+
상태
+
{reservation.statusDescription}
+
+
+ + {reservation.qrCode ? ( +
+ +
+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx b/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx new file mode 100644 index 0000000..a2bd400 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import PerformanceCard from "@/components/performances/PerformanceCard"; +import type { Performance } from "@/types/performance/performance.types"; + +const performance: Performance = { + id: 1, + title: "와우 스테이지 헤드라이너", + artist: "헤드라이너 아티스트", + startsAt: "2026-05-13T19:00:00", + endsAt: "2026-05-13T21:00:00", + location: "대운동장 메인 스테이지", + description: "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + totalSeats: 500, + remainingSeats: 128, +}; + +describe("PerformanceCard", () => { + it("공연 요약과 상세 링크를 표시한다", () => { + render(); + + expect(screen.getByText("헤드라이너 아티스트")).toBeInTheDocument(); + expect(screen.getByText("와우 스테이지 헤드라이너")).toBeInTheDocument(); + expect(screen.getByText("대운동장 메인 스테이지")).toBeInTheDocument(); + expect(screen.getByText("잔여 128석")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "상세 보기" })).toHaveAttribute( + "href", + "/performances/1", + ); + }); +}); diff --git a/festival-app/frontend/src/components/performances/PerformanceCard.tsx b/festival-app/frontend/src/components/performances/PerformanceCard.tsx new file mode 100644 index 0000000..747201a --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceCard.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; + +import type { Performance } from "@/types/performance/performance.types"; +import { + formatPerformanceTimeRange, + getRemainingSeatLabel, +} from "@/utils/performance/performance.utils"; + +type PerformanceCardProps = { + performance: Performance; +}; + +export default function PerformanceCard({ performance }: PerformanceCardProps) { + return ( +
+

{performance.artist}

+

{performance.title}

+

+ {formatPerformanceTimeRange(performance)} +

+

{performance.location}

+

+ {performance.description} +

+
+ + {getRemainingSeatLabel(performance.remainingSeats)} + + + 상세 보기 + +
+
+ ); +} diff --git a/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx new file mode 100644 index 0000000..6e742e8 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx @@ -0,0 +1,39 @@ +import type { Performance } from "@/types/performance/performance.types"; +import { + formatPerformanceTimeRange, + getRemainingSeatLabel, +} from "@/utils/performance/performance.utils"; + +type PerformanceDetailPanelProps = { + performance: Performance; +}; + +export default function PerformanceDetailPanel({ performance }: PerformanceDetailPanelProps) { + return ( +
+

{performance.artist}

+

{performance.title}

+ +
+
+
공연 시간
+
{formatPerformanceTimeRange(performance)}
+
+
+
장소
+
{performance.location}
+
+
+
좌석
+
+ {getRemainingSeatLabel(performance.remainingSeats)} +
+
+
+ +

+ {performance.description} +

+
+ ); +} diff --git a/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx new file mode 100644 index 0000000..65eb9e7 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import PerformanceReservationPanel from "@/components/performances/PerformanceReservationPanel"; +import type { Performance } from "@/types/performance/performance.types"; +import type { TicketReservation } from "@/types/ticket/ticket.types"; + +const performance: Performance = { + id: 1, + title: "와우 스테이지 헤드라이너", + artist: "헤드라이너 아티스트", + startsAt: "2026-05-13T19:00:00", + endsAt: "2026-05-13T21:00:00", + location: "대운동장 메인 스테이지", + description: "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + totalSeats: 500, + remainingSeats: 128, +}; + +const reservation: TicketReservation = { + id: "ticket-1", + performanceId: 1, + userId: "user-1", + status: "COMPLETED", + statusDescription: "예매 완료", + qrCode: "http://localhost:3000/performances/tickets/ticket-1", + sagaLogs: [ + { + step: "SEAT_CHECKED", + message: "잔여 좌석을 확인했습니다.", + createdAt: "2026-05-13T19:00:00", + }, + { + step: "SEAT_HELD", + message: "좌석을 선점했습니다.", + createdAt: "2026-05-13T19:00:00", + }, + { + step: "RESERVATION_CREATED", + message: "예매 정보를 생성했습니다.", + createdAt: "2026-05-13T19:00:00", + }, + { + step: "QR_ISSUED", + message: "QR 티켓을 발급했습니다.", + createdAt: "2026-05-13T19:00:00", + }, + { + step: "COMPLETED", + message: "예매를 완료했습니다.", + createdAt: "2026-05-13T19:00:00", + }, + ], + createdAt: "2026-05-13T19:00:00", + updatedAt: "2026-05-13T19:00:00", +}; + +describe("PerformanceReservationPanel", () => { + it("로그인 사용자에게 예매 버튼을 표시하고 클릭 시 핸들러를 호출한다", async () => { + const user = userEvent.setup(); + const handleReserve = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "좌석 선점하고 예매하기" })); + + expect(handleReserve).toHaveBeenCalledOnce(); + }); + + it("예매 완료 후 QR 티켓을 표시하고 내부 Saga 단계는 숨긴다", () => { + render( + , + ); + + expect(screen.getByText("예매가 완료되었습니다")).toBeInTheDocument(); + expect( + screen.getByLabelText("공연 티켓 QR http://localhost:3000/performances/tickets/ticket-1"), + ).toBeInTheDocument(); + expect(screen.getByText("예매 번호 ticket-1")).toBeInTheDocument(); + expect(screen.queryByText("SEAT_CHECKED")).not.toBeInTheDocument(); + expect(screen.queryByText("COMPLETED")).not.toBeInTheDocument(); + }); +}); diff --git a/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx new file mode 100644 index 0000000..37683b0 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; + +import type { Performance } from "@/types/performance/performance.types"; +import type { TicketReservation } from "@/types/ticket/ticket.types"; + +import TicketReservationResult from "./TicketReservationResult"; + +type PerformanceReservationPanelProps = { + isAuthenticated: boolean; + isInitialized: boolean; + isSubmitting: boolean; + performance: Performance; + reservation: TicketReservation | null; + errorMessage: string; + onReserve: () => void; +}; + +export default function PerformanceReservationPanel({ + isAuthenticated, + isInitialized, + isSubmitting, + performance, + reservation, + errorMessage, + onReserve, +}: PerformanceReservationPanelProps) { + const isSoldOut = performance.remainingSeats <= 0; + + if (reservation) { + return ; + } + + return ( +
+

티켓 예매

+

+ 좌석 확인, 좌석 선점, 예매 생성, QR 티켓 발급, 예매 완료 순서로 진행됩니다. +

+ +
+

잔여 좌석

+

+ {performance.remainingSeats.toLocaleString("ko-KR")}석 +

+
+ + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + {!isInitialized ? ( + + ) : !isAuthenticated ? ( + + 로그인하고 예매하기 + + ) : ( + + )} +
+ ); +} diff --git a/festival-app/frontend/src/components/performances/TicketQr.tsx b/festival-app/frontend/src/components/performances/TicketQr.tsx new file mode 100644 index 0000000..4cf9ac7 --- /dev/null +++ b/festival-app/frontend/src/components/performances/TicketQr.tsx @@ -0,0 +1,14 @@ +import { QRCodeSVG } from "qrcode.react"; + +type TicketQrProps = { + qrCode: string; +}; + +export default function TicketQr({ qrCode }: TicketQrProps) { + return ( +
+ + {qrCode} +
+ ); +} diff --git a/festival-app/frontend/src/components/performances/TicketReservationResult.tsx b/festival-app/frontend/src/components/performances/TicketReservationResult.tsx new file mode 100644 index 0000000..d5450d6 --- /dev/null +++ b/festival-app/frontend/src/components/performances/TicketReservationResult.tsx @@ -0,0 +1,24 @@ +import type { TicketReservation } from "@/types/ticket/ticket.types"; + +import TicketQr from "./TicketQr"; + +type TicketReservationResultProps = { + reservation: TicketReservation; +}; + +export default function TicketReservationResult({ reservation }: TicketReservationResultProps) { + return ( +
+

TICKET RESERVED

+

예매가 완료되었습니다

+

현장 입장 시 아래 QR 티켓을 제시해주세요.

+

예매 번호 {reservation.id}

+ + {reservation.qrCode ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/festival-app/frontend/src/types/performance/performance.types.ts b/festival-app/frontend/src/types/performance/performance.types.ts new file mode 100644 index 0000000..d607186 --- /dev/null +++ b/festival-app/frontend/src/types/performance/performance.types.ts @@ -0,0 +1,11 @@ +export type Performance = { + id: number; + title: string; + artist: string; + startsAt: string; + endsAt: string; + location: string; + description: string; + totalSeats: number; + remainingSeats: number; +}; diff --git a/festival-app/frontend/src/types/ticket/ticket.types.ts b/festival-app/frontend/src/types/ticket/ticket.types.ts new file mode 100644 index 0000000..2dbbebc --- /dev/null +++ b/festival-app/frontend/src/types/ticket/ticket.types.ts @@ -0,0 +1,28 @@ +export type TicketReservationStatus = + | "SEAT_HELD" + | "RESERVATION_CREATED" + | "QR_ISSUED" + | "COMPLETED" + | "CANCELLED"; + +export type TicketReservationSagaLog = { + step: string; + message: string; + createdAt: string; +}; + +export type TicketReservation = { + id: string; + performanceId: number; + userId: string; + status: TicketReservationStatus; + statusDescription: string; + qrCode: string | null; + sagaLogs: TicketReservationSagaLog[]; + createdAt: string; + updatedAt: string; +}; + +export type TicketReservationCreateRequest = { + performanceId: number; +}; diff --git a/festival-app/frontend/src/utils/performance/performance.utils.ts b/festival-app/frontend/src/utils/performance/performance.utils.ts new file mode 100644 index 0000000..1ba145f --- /dev/null +++ b/festival-app/frontend/src/utils/performance/performance.utils.ts @@ -0,0 +1,25 @@ +import type { Performance } from "@/types/performance/performance.types"; + +export function formatPerformanceDateTime(value: string) { + return new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +export function formatPerformanceTimeRange(performance: Performance) { + return `${formatPerformanceDateTime(performance.startsAt)} - ${formatPerformanceDateTime( + performance.endsAt, + )}`; +} + +export function getRemainingSeatLabel(remainingSeats: number) { + if (remainingSeats <= 0) { + return "매진"; + } + + return `잔여 ${remainingSeats.toLocaleString("ko-KR")}석`; +}