From 5e22823b554814c1ca8a9e0d1fb59b4e1edcd15e Mon Sep 17 00:00:00 2001 From: pokerbearkr Date: Fri, 16 May 2025 10:00:41 +0900 Subject: [PATCH 01/32] =?UTF-8?q?docs=20:=20PR=20Template=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..863b472 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,54 @@ +> PR 생성 시 아래 항목을 채워주세요. +> +> +> **제목 예시:** `feat : Pull request template 작성` +> +> (✅ 작성 후 이 안내 문구는 삭제해주세요) +> + +--- + +## 🔎 작업 내용 + +- 어떤 기능(또는 수정 사항)을 구현했는지 간략하게 설명해주세요. +- 예) "회원가입 API에 이메일 중복 검사 기능 추가" + +--- + +## 🛠️ 변경 사항 + +- 구현한 주요 로직, 클래스, 메서드 등을 bullet 형식으로 기술해주세요. +- 예) + - `UserService.createUser()` 메서드 추가 + - `@Email` 유효성 검증 적용 + +--- + +## 🧩 트러블 슈팅 + +- 구현 중 마주한 문제와 해결 방법을 기술해주세요. +- 예) + - 문제: `@Transactional`이 적용되지 않음 + - 해결: 메서드 호출 방식 변경 (`this.` → `AopProxyUtils.` 사용) + +--- + +## 🧯 해결해야 할 문제 + +- 기능은 동작하지만 리팩토링이나 논의가 필요한 부분을 적어주세요. +- 예)D + - `UserController`에서 비즈니스 로직 일부 처리 → 서비스로 이전 고려 필요 + +--- + +## 📌 참고 사항 + +- 기타 공유하고 싶은 정보나 참고한 문서(링크 등)가 있다면 작성해주세요. + +--- + +### 🙏 코드 리뷰 전 확인 체크리스트 + +- [ ] 불필요한 콘솔 로그, 주석 제거 +- [ ] 커밋 메시지 컨벤션 준수 (`type : `) +- [ ] 기능 정상 동작 확인 \ No newline at end of file From eba9b0674b183b7ab7f2c4be6844e3b050dcbf97 Mon Sep 17 00:00:00 2001 From: pokerbearkr Date: Fri, 16 May 2025 10:58:43 +0900 Subject: [PATCH 02/32] =?UTF-8?q?docs=20:=20readmd.md=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17b1070..8d7a086 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# tempname -내일배움캠프 배달어플만들기(2025.05.16~2025.05.26) +# nullnullTicket +내일배움캠프 티케팅사이트만들기(2025.05.16~2025.05.26) From 4e3b58ef4ba19812a559ab07341536d0ba516884 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Fri, 16 May 2025 23:24:32 +0900 Subject: [PATCH 03/32] =?UTF-8?q?feat=20:=20entity=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/entity/Reservation.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index ecdb59b..7d17a84 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -2,6 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; @@ -10,6 +11,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.Getter; @@ -17,11 +19,14 @@ import org.example.siljeun.domain.seat.entity.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Getter @NoArgsConstructor @Entity @Table(name = "reservation") +@EntityListeners(AuditingEntityListener.class) public class Reservation { @Id @@ -33,19 +38,18 @@ public class Reservation { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "seat_status_id", nullable = false) + @JoinColumn(name = "seat_status_id") private SeatStatus seatStatus; - @Column(nullable = false) private int price; @Column(nullable = false) @Enumerated(EnumType.STRING) - private DeliveryFee deliveryFee; + private TicketReceipt ticketReceipt; @Column(nullable = false) @Enumerated(EnumType.STRING) - private DiscountRate discountRate; + private Discount discount; @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -55,11 +59,11 @@ public class Reservation { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime created_at; - public enum DeliveryFee { + public enum TicketReceipt { DELIVERY, PICKUP } - public enum DiscountRate { + public enum Discount { GENERAL, SEVERELY, MILDLY, NATIONAL_MERIT // 일반, 중증, 경증, 국가유공자 } From f8583b03f67fbe7beb5b562a8847949f9d74f500 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Sun, 18 May 2025 19:12:39 +0900 Subject: [PATCH 04/32] =?UTF-8?q?refactor=20:=20enum=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20repo=EC=97=90=20JP?= =?UTF-8?q?A=20=EC=83=81=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/entity/Reservation.java | 17 +++-------------- .../domain/reservation/enums/Discount.java | 8 ++++++++ .../reservation/enums/ReservationStatus.java | 5 +++++ .../domain/reservation/enums/TicketReceipt.java | 8 ++++++++ .../repository/ReservationRepository.java | 1 + 5 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/enums/Discount.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/enums/TicketReceipt.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index 7d17a84..352404f 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -11,16 +11,17 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.MappedSuperclass; import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; +import org.example.siljeun.domain.reservation.enums.Discount; +import org.example.siljeun.domain.reservation.enums.ReservationStatus; +import org.example.siljeun.domain.reservation.enums.TicketReceipt; import org.example.siljeun.domain.seat.entity.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Getter @NoArgsConstructor @@ -58,16 +59,4 @@ public class Reservation { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime created_at; - - public enum TicketReceipt { - DELIVERY, PICKUP - } - - public enum Discount { - GENERAL, SEVERELY, MILDLY, NATIONAL_MERIT // 일반, 중증, 경증, 국가유공자 - } - - public enum ReservationStatus { - PENDING, COMPLETE - } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/enums/Discount.java b/src/main/java/org/example/siljeun/domain/reservation/enums/Discount.java new file mode 100644 index 0000000..c1d02aa --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/enums/Discount.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.reservation.enums; + +public enum Discount { + GENERAL(0.0), SEVERELY(0.3), MILDLY(0.3), NATIONAL_MERIT(0.3); // 일반, 중증, 경증, 국가유공자 + + Discount(double discountRate) { + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java b/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java new file mode 100644 index 0000000..72dc51e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.reservation.enums; + +public enum ReservationStatus { + PENDING, COMPLETE +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/enums/TicketReceipt.java b/src/main/java/org/example/siljeun/domain/reservation/enums/TicketReceipt.java new file mode 100644 index 0000000..5a890ef --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/enums/TicketReceipt.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.reservation.enums; + +public enum TicketReceipt { + DELIVERY(3000), PICKUP(0); + + TicketReceipt(int ticketReceiptFee) { + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java index 961ac77..16071ef 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java @@ -1,5 +1,6 @@ package org.example.siljeun.domain.reservation.repository; +import org.example.siljeun.domain.reservation.entity.Reservation; import org.springframework.data.jpa.repository.JpaRepository; public interface ReservationRepository extends JpaRepository { From d4d168e622dd350ed486a71bddcd6f96cd565fe0 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Sun, 18 May 2025 21:16:38 +0900 Subject: [PATCH 05/32] =?UTF-8?q?feat=20:=20create(),=20saveSeatInfo()=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=8B=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 18 +++++++++++ .../reservation/entity/Reservation.java | 18 +++++++++-- .../service/ReservationService.java | 31 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 1c8d5b6..2d54e75 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -1,5 +1,23 @@ package org.example.siljeun.domain.reservation.controller; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.service.ReservationService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor public class ReservationController { + private final ReservationService reservationService; + + @PostMapping("/schedules/{scheduleId}/reservations") + public ResponseEntity create(@PathVariable Long scheduleId) { + // Todo : user 정보 가져와서 service에 전달 + reservationService.create(scheduleId); + return null; + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index 352404f..a687ddc 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -18,6 +18,7 @@ import org.example.siljeun.domain.reservation.enums.Discount; import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.enums.TicketReceipt; +import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.seat.entity.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.springframework.data.annotation.CreatedDate; @@ -39,8 +40,12 @@ public class Reservation { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "seat_status_id") - private SeatStatus seatStatus; + @JoinColumn(name = "schedule_id", nullable = false) + private Schedule schedule; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "seat_schedule_info_id") + private SeatScheduleInfo seatScheduleInfo; private int price; @@ -59,4 +64,13 @@ public class Reservation { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime created_at; + + public Reservation(User user, Schedule schedule) { + this.user = user; + this.schedule = schedule; + } + + public void updateSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { + this.seatScheduleInfo = seatScheduleInfo; + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index da8d11d..75cdf6a 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -1,5 +1,36 @@ package org.example.siljeun.domain.reservation.service; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.entity.Reservation; +import org.example.siljeun.domain.reservation.repository.ReservationRepository; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.user.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor public class ReservationService { + private final ReservationRepository reservationRepository; + private final ScheduleRepository scheduleRepository; + private final SeatScheduleInfo seatScheduleInfo; + + // Todo : 예외처리 + + public void create(Long scheduleId) { + Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(RuntimeException::new); + User user = null; // Todo : User 데이터 DB에 있는지 확인 + Reservation reservation = new Reservation(user, schedule); + reservationRepository.save(reservation); + } + + // 좌석 도메인에서 호출할 메서드(해당 도메인에서 reservation Repo에 reservationId가 존재하는지 확인 필요) + @Transactional + public void saveSeatInfo(Reservation reservation, SeatScheduleInfo seatScheduleInfo) { + reservation.updateSeatScheduleInfo(seatScheduleInfo); + } + + } From 8a39d8a2c27575116d5b89a176214f573eb14f43 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 19 May 2025 00:13:56 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat=20:=20=EC=98=88=EB=A7=A4=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EB=8A=94=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../siljeun/domain/reservation/entity/Reservation.java | 8 ++++++++ .../domain/reservation/service/ReservationService.java | 9 ++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index b229b64..f913e20 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -68,9 +68,17 @@ public class Reservation { public Reservation(User user, Schedule schedule) { this.user = user; this.schedule = schedule; + this.ticketReceipt = TicketReceipt.PICKUP; + this.discount = Discount.GENERAL; + this.status = ReservationStatus.PENDING; } public void updateSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { this.seatScheduleInfo = seatScheduleInfo; + this.price = seatScheduleInfo.getPrice(); + } + + public void updateReservationStatus() { + this.status = ReservationStatus.COMPLETE; } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 58defb8..7feddd4 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -16,7 +16,6 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final ScheduleRepository scheduleRepository; - private final SeatScheduleInfo seatScheduleInfo; // Todo : 예외처리 @@ -27,11 +26,15 @@ public void create(Long scheduleId) { reservationRepository.save(reservation); } - // 좌석 도메인에서 호출할 메서드(해당 도메인에서 reservation Repo에 reservationId가 존재하는지 확인 필요) + // 좌석 도메인에서 호출할 메서드(해당 도메인에서 reservation Repo에 reservationId가 존재하는지 확인하는 과정 필요) - 예매 테이블에 좌석 정보 저장 @Transactional public void saveSeatInfo(Reservation reservation, SeatScheduleInfo seatScheduleInfo) { reservation.updateSeatScheduleInfo(seatScheduleInfo); } - + // 결제 도메인에서 호출할 메서드 - 결제완료 처리 + @Transactional + public void updateReservationStatus(Reservation reservation) { + reservation.updateReservationStatus(); + } } From 313afdeb0f0a519222b12ac33fb1d47c7ee391c3 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 19 May 2025 10:44:56 +0900 Subject: [PATCH 07/32] =?UTF-8?q?feat=20:=20updatePrice()=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 9 +++++++++ .../siljeun/domain/reservation/dto/.gitkeep | 0 .../dto/request/UpdatePriceRequest.java | 8 ++++++++ .../reservation/entity/Reservation.java | 15 +++++++++++++- .../service/ReservationService.java | 20 ++++++++++++++++--- 5 files changed, 48 insertions(+), 4 deletions(-) delete mode 100644 src/main/java/org/example/siljeun/domain/reservation/dto/.gitkeep create mode 100644 src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 2d54e75..078fc92 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -1,11 +1,13 @@ package org.example.siljeun.domain.reservation.controller; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.service.ReservationService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; 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.RestController; @RestController @@ -20,4 +22,11 @@ public ResponseEntity create(@PathVariable Long scheduleId) { reservationService.create(scheduleId); return null; } + + @PatchMapping("/reservations/{reservationId}/discount") + public ResponseEntity updatePrice(@PathVariable Long reservationId, + @RequestBody UpdatePriceRequest requestDto) { + // Todo : user 정보 가져와서 service에 전달 + reservationService.updatePrice(reservationId, requestDto); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/.gitkeep b/src/main/java/org/example/siljeun/domain/reservation/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java new file mode 100644 index 0000000..d40c1a7 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.reservation.dto.request; + +public record UpdatePriceRequest( + String ticketReceipt, + String discount +) { + +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index f913e20..bb4846f 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -1,5 +1,6 @@ package org.example.siljeun.domain.reservation.entity; +import io.micrometer.common.util.StringUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -15,12 +16,14 @@ import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; +import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.enums.Discount; import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.enums.TicketReceipt; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; +import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -29,6 +32,7 @@ @Entity @Table(name = "reservation") @EntityListeners(AuditingEntityListener.class) +@DynamicUpdate public class Reservation { @Id @@ -73,7 +77,7 @@ public Reservation(User user, Schedule schedule) { this.status = ReservationStatus.PENDING; } - public void updateSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { + public void saveSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { this.seatScheduleInfo = seatScheduleInfo; this.price = seatScheduleInfo.getPrice(); } @@ -81,4 +85,13 @@ public void updateSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { public void updateReservationStatus() { this.status = ReservationStatus.COMPLETE; } + + public void updateTicketPrice(UpdatePriceRequest dto) { + if (!StringUtils.isBlank(dto.ticketReceipt())) { + this.ticketReceipt = TicketReceipt.valueOf(dto.ticketReceipt()); + } + if (!StringUtils.isBlank(dto.discount())) { + this.discount = Discount.valueOf(dto.discount()); + } + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 7feddd4..1e968d1 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.reservation.service; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.entity.Reservation; import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.schedule.entity.Schedule; @@ -20,16 +21,16 @@ public class ReservationService { // Todo : 예외처리 public void create(Long scheduleId) { - Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(RuntimeException::new); User user = null; // Todo : User 데이터 DB에 있는지 확인 + Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(RuntimeException::new); Reservation reservation = new Reservation(user, schedule); reservationRepository.save(reservation); } - // 좌석 도메인에서 호출할 메서드(해당 도메인에서 reservation Repo에 reservationId가 존재하는지 확인하는 과정 필요) - 예매 테이블에 좌석 정보 저장 + // 좌석 도메인에서 호출할 메서드 - 예매 테이블에 좌석 정보 저장 @Transactional public void saveSeatInfo(Reservation reservation, SeatScheduleInfo seatScheduleInfo) { - reservation.updateSeatScheduleInfo(seatScheduleInfo); + reservation.saveSeatScheduleInfo(seatScheduleInfo); } // 결제 도메인에서 호출할 메서드 - 결제완료 처리 @@ -37,4 +38,17 @@ public void saveSeatInfo(Reservation reservation, SeatScheduleInfo seatScheduleI public void updateReservationStatus(Reservation reservation) { reservation.updateReservationStatus(); } + + @Transactional + public void updatePrice(Long reservationId, UpdatePriceRequest requestDto) { + User user = null; // Todo : User 데이터 DB에 있는지 확인 + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( + RuntimeException::new); + + if (reservation.getUser() != user) { + throw new RuntimeException(); + } + + reservation.updateTicketPrice(requestDto); + } } From 35df0c42f5f3e62532da32533a01e5dfdc52c0dc Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 19 May 2025 14:07:13 +0900 Subject: [PATCH 08/32] =?UTF-8?q?feat=20:=20=EC=9D=BC=EC=A0=95=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=9B=84=20=20=EC=A2=8C=EC=84=9D=EB=B0=98=ED=99=98?= =?UTF-8?q?,=20=EA=B3=B5=ED=86=B5=EC=9D=91=EB=8B=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 8 +++-- .../repository/ReservationRepository.java | 3 ++ .../service/ReservationService.java | 10 ++++++ .../task/ReservationScheduler.java | 35 +++++++++++++++++++ .../example/siljeun/global/dto/Response.java | 22 ++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java create mode 100644 src/main/java/org/example/siljeun/global/dto/Response.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 078fc92..f277a17 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.service.ReservationService; +import org.example.siljeun.global.dto.Response; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -17,16 +18,17 @@ public class ReservationController { private final ReservationService reservationService; @PostMapping("/schedules/{scheduleId}/reservations") - public ResponseEntity create(@PathVariable Long scheduleId) { + public ResponseEntity> create(@PathVariable Long scheduleId) { // Todo : user 정보 가져와서 service에 전달 reservationService.create(scheduleId); - return null; + return ResponseEntity.ok(Response.from("예매 시작")); } @PatchMapping("/reservations/{reservationId}/discount") - public ResponseEntity updatePrice(@PathVariable Long reservationId, + public ResponseEntity> updatePrice(@PathVariable Long reservationId, @RequestBody UpdatePriceRequest requestDto) { // Todo : user 정보 가져와서 service에 전달 reservationService.updatePrice(reservationId, requestDto); + return ResponseEntity.ok(Response.from("예매 금액 변경 완료")); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java index 16071ef..6e43ed0 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java @@ -1,8 +1,11 @@ package org.example.siljeun.domain.reservation.repository; +import java.util.List; import org.example.siljeun.domain.reservation.entity.Reservation; +import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.springframework.data.jpa.repository.JpaRepository; public interface ReservationRepository extends JpaRepository { + List findByStatus(ReservationStatus status); } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 1e968d1..f483e71 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -23,6 +23,7 @@ public class ReservationService { public void create(Long scheduleId) { User user = null; // Todo : User 데이터 DB에 있는지 확인 Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(RuntimeException::new); + // Todo : 티켓팅 가능 시간인지 확인 Reservation reservation = new Reservation(user, schedule); reservationRepository.save(reservation); } @@ -51,4 +52,13 @@ public void updatePrice(Long reservationId, UpdatePriceRequest requestDto) { reservation.updateTicketPrice(requestDto); } + + @Transactional + public void delete(Long reservationId) { + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( + RuntimeException::new); + + reservationRepository.delete(reservation); + // Todo : seatScheduleInfo 테이블에 해당 좌석 선택가능으로 변경 + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java new file mode 100644 index 0000000..bc9a52e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java @@ -0,0 +1,35 @@ +package org.example.siljeun.domain.reservation.task; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import org.example.siljeun.domain.reservation.enums.ReservationStatus; +import org.example.siljeun.domain.reservation.repository.ReservationRepository; +import org.example.siljeun.domain.reservation.service.ReservationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@EnableScheduling +@Component +public class ReservationScheduler { + + private final ReservationRepository reservationRepository; + private final ReservationService reservationService; + + @Autowired + public ReservationScheduler(ReservationRepository reservationRepository, + ReservationService reservationService) { + this.reservationRepository = reservationRepository; + this.reservationService = reservationService; + } + + @Scheduled(fixedRate = 60000) // 1분마다 실행 + public void returnSeat() { + LocalDateTime now = LocalDateTime.now(); + + reservationRepository.findByStatus(ReservationStatus.PENDING).stream() + .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreated_at(), now) >= 7) + .forEach(reservation -> reservationService.delete(reservation.getId())); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/dto/Response.java b/src/main/java/org/example/siljeun/global/dto/Response.java new file mode 100644 index 0000000..464ede3 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/dto/Response.java @@ -0,0 +1,22 @@ +package org.example.siljeun.global.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +public record Response( + + boolean success, + String message, + @JsonInclude(Include.NON_NULL) + T data +) { + + public static Response from(String message) { + return new Response<>(true, message, null); + } +} +// 컨트롤러에서 반환타입 선언할 때 Response<> 내부 제네릭 타입을 null로 하는지 String으로 하는지? +// 메서드에서 제네릭 타입 두 번 써야하는 이유 + +// 제네릭은 빌드 타임에 타입 안정성 확보 -> 실행됐을때는 타입이 고정됨 +// 제네릭은 형태를 고정하는 것. From 8a92d527af7b16dacf1c764ace59caa16ef3b728 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 19 May 2025 14:42:20 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat=20:=20reservation=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/exception/ErrorCode.java | 16 ++++++++++++++++ .../exception/ReservationCustomException.java | 14 ++++++++++++++ .../exception/ReservationExceptionHandler.java | 17 +++++++++++++++++ .../reservation/service/ReservationService.java | 8 +++++--- .../example/siljeun/global/dto/Response.java | 9 ++++----- 5 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java new file mode 100644 index 0000000..aa51b6a --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -0,0 +1,16 @@ +package org.example.siljeun.domain.reservation.exception; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + NOT_FOUND_RESERVATION(404, "예매정보가 존재하지 않습니다."), + INVALID_RESERVATION_USER(400, "예매정보가 일치하지 않습니다."); + + private int code; + private String message; + + ErrorCode(int errorCode, String message) { + } + +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java new file mode 100644 index 0000000..e1db58e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java @@ -0,0 +1,14 @@ +package org.example.siljeun.domain.reservation.exception; + +import lombok.Getter; + +public class ReservationCustomException extends RuntimeException { + + @Getter + private int errorCode; + + public ReservationCustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode.getCode(); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java new file mode 100644 index 0000000..3f9d6e6 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java @@ -0,0 +1,17 @@ +package org.example.siljeun.domain.reservation.exception; + +import org.example.siljeun.global.dto.Response; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ReservationExceptionHandler { + + @ExceptionHandler + public ResponseEntity> ReservationExceptionHandler( + ReservationCustomException e) { + return ResponseEntity.status(e.getErrorCode()) + .body(Response.of(false, e.getMessage())); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index f483e71..b8e72e2 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.entity.Reservation; +import org.example.siljeun.domain.reservation.exception.ErrorCode; +import org.example.siljeun.domain.reservation.exception.ReservationCustomException; import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; @@ -44,10 +46,10 @@ public void updateReservationStatus(Reservation reservation) { public void updatePrice(Long reservationId, UpdatePriceRequest requestDto) { User user = null; // Todo : User 데이터 DB에 있는지 확인 Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( - RuntimeException::new); + () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); if (reservation.getUser() != user) { - throw new RuntimeException(); + throw new ReservationCustomException(ErrorCode.INVALID_RESERVATION_USER); } reservation.updateTicketPrice(requestDto); @@ -56,7 +58,7 @@ public void updatePrice(Long reservationId, UpdatePriceRequest requestDto) { @Transactional public void delete(Long reservationId) { Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( - RuntimeException::new); + () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); reservationRepository.delete(reservation); // Todo : seatScheduleInfo 테이블에 해당 좌석 선택가능으로 변경 diff --git a/src/main/java/org/example/siljeun/global/dto/Response.java b/src/main/java/org/example/siljeun/global/dto/Response.java index 464ede3..da3caca 100644 --- a/src/main/java/org/example/siljeun/global/dto/Response.java +++ b/src/main/java/org/example/siljeun/global/dto/Response.java @@ -14,9 +14,8 @@ public record Response( public static Response from(String message) { return new Response<>(true, message, null); } -} -// 컨트롤러에서 반환타입 선언할 때 Response<> 내부 제네릭 타입을 null로 하는지 String으로 하는지? -// 메서드에서 제네릭 타입 두 번 써야하는 이유 -// 제네릭은 빌드 타임에 타입 안정성 확보 -> 실행됐을때는 타입이 고정됨 -// 제네릭은 형태를 고정하는 것. + public static Response of(boolean success, String message) { + return new Response<>(success, message, null); + } +} From cd648b6c95081b1ade2829867d5163ff816658d2 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 19 May 2025 17:21:10 +0900 Subject: [PATCH 10/32] =?UTF-8?q?refactor=20:=20=EB=8B=A4=EB=A5=B8=20api?= =?UTF-8?q?=EC=99=80=20=EC=A4=91=EB=B3=B5=EB=90=98=EB=8A=94=20api=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 15 +++-------- .../reservation/entity/Reservation.java | 18 ++++--------- .../service/ReservationService.java | 27 ++++++------------- 3 files changed, 17 insertions(+), 43 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index f277a17..b4fec2c 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -7,7 +7,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -17,18 +17,11 @@ public class ReservationController { private final ReservationService reservationService; - @PostMapping("/schedules/{scheduleId}/reservations") - public ResponseEntity> create(@PathVariable Long scheduleId) { - // Todo : user 정보 가져와서 service에 전달 - reservationService.create(scheduleId); - return ResponseEntity.ok(Response.from("예매 시작")); - } - @PatchMapping("/reservations/{reservationId}/discount") - public ResponseEntity> updatePrice(@PathVariable Long reservationId, + public ResponseEntity> updatePrice(@RequestAttribute Long userId, + @PathVariable Long reservationId, @RequestBody UpdatePriceRequest requestDto) { - // Todo : user 정보 가져와서 service에 전달 - reservationService.updatePrice(reservationId, requestDto); + reservationService.updatePrice(userId, reservationId, requestDto); return ResponseEntity.ok(Response.from("예매 금액 변경 완료")); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index bb4846f..2997975 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -20,7 +20,6 @@ import org.example.siljeun.domain.reservation.enums.Discount; import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.enums.TicketReceipt; -import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; import org.hibernate.annotations.DynamicUpdate; @@ -44,13 +43,10 @@ public class Reservation { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "schedule_id", nullable = false) - private Schedule schedule; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "seat_schedule_info_id") + @JoinColumn(name = "seat_schedule_info_id", nullable = false) private SeatScheduleInfo seatScheduleInfo; + @Column(nullable = false) private int price; @Column(nullable = false) @@ -69,19 +65,15 @@ public class Reservation { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime created_at; - public Reservation(User user, Schedule schedule) { + public Reservation(User user, SeatScheduleInfo seatScheduleInfo) { this.user = user; - this.schedule = schedule; + this.seatScheduleInfo = seatScheduleInfo; + this.price = seatScheduleInfo.getPrice(); this.ticketReceipt = TicketReceipt.PICKUP; this.discount = Discount.GENERAL; this.status = ReservationStatus.PENDING; } - public void saveSeatScheduleInfo(SeatScheduleInfo seatScheduleInfo) { - this.seatScheduleInfo = seatScheduleInfo; - this.price = seatScheduleInfo.getPrice(); - } - public void updateReservationStatus() { this.status = ReservationStatus.COMPLETE; } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index b8e72e2..7e49507 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -6,10 +6,9 @@ import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.reservation.exception.ReservationCustomException; import org.example.siljeun.domain.reservation.repository.ReservationRepository; -import org.example.siljeun.domain.schedule.entity.Schedule; -import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; +import org.example.siljeun.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,22 +17,13 @@ public class ReservationService { private final ReservationRepository reservationRepository; - private final ScheduleRepository scheduleRepository; + private final UserRepository userRepository; - // Todo : 예외처리 - - public void create(Long scheduleId) { - User user = null; // Todo : User 데이터 DB에 있는지 확인 - Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(RuntimeException::new); - // Todo : 티켓팅 가능 시간인지 확인 - Reservation reservation = new Reservation(user, schedule); - reservationRepository.save(reservation); - } - - // 좌석 도메인에서 호출할 메서드 - 예매 테이블에 좌석 정보 저장 + // 좌석 도메인에서 호출할 메서드 - 예매 정보 저장 @Transactional - public void saveSeatInfo(Reservation reservation, SeatScheduleInfo seatScheduleInfo) { - reservation.saveSeatScheduleInfo(seatScheduleInfo); + public void save(User user, SeatScheduleInfo seatScheduleInfo) { + Reservation reservation = new Reservation(user, seatScheduleInfo); + reservationRepository.save(reservation); } // 결제 도메인에서 호출할 메서드 - 결제완료 처리 @@ -43,8 +33,8 @@ public void updateReservationStatus(Reservation reservation) { } @Transactional - public void updatePrice(Long reservationId, UpdatePriceRequest requestDto) { - User user = null; // Todo : User 데이터 DB에 있는지 확인 + public void updatePrice(Long userId, Long reservationId, UpdatePriceRequest requestDto) { + User user = userRepository.findById(userId).orElseThrow(RuntimeException::new); Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); @@ -61,6 +51,5 @@ public void delete(Long reservationId) { () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); reservationRepository.delete(reservation); - // Todo : seatScheduleInfo 테이블에 해당 좌석 선택가능으로 변경 } } From f6d925aa3573864e53c3309277d45de96e26879b Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Tue, 20 May 2025 15:48:54 +0900 Subject: [PATCH 11/32] =?UTF-8?q?feat=20:=20redis=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=98=88=EB=A7=A4=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++ .../example/siljeun/SiljeunApplication.java | 2 + .../controller/WaitingQueueController.java | 21 +++++++++ .../dto/request/AddQueueRequest.java | 8 ++++ .../dto/response/MyQueueInfoResponse.java | 10 +++++ .../service/ReservationService.java | 1 + .../service/WaitingQueueService.java | 43 +++++++++++++++++++ .../task/ReservationScheduler.java | 2 - .../global/config/WebSocketConfig.java | 25 +++++++++++ src/main/resources/application.properties | 2 + 10 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java create mode 100644 src/main/java/org/example/siljeun/global/config/WebSocketConfig.java diff --git a/build.gradle b/build.gradle index 036b2ad..ebdd620 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,10 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // waiting queue + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { diff --git a/src/main/java/org/example/siljeun/SiljeunApplication.java b/src/main/java/org/example/siljeun/SiljeunApplication.java index b38f6cf..9495058 100644 --- a/src/main/java/org/example/siljeun/SiljeunApplication.java +++ b/src/main/java/org/example/siljeun/SiljeunApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class SiljeunApplication { diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java new file mode 100644 index 0000000..d0dd94b --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -0,0 +1,21 @@ +package org.example.siljeun.domain.reservation.controller; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.request.AddQueueRequest; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class WaitingQueueController { + + private final WaitingQueueService waitingQueueService; + + @MessageMapping("/addQueue") + public void addQueue(AddQueueRequest request) { + Long scheduleId = request.scheduleId(); + Long userId = request.userId(); + waitingQueueService.addQueue(scheduleId, userId); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java new file mode 100644 index 0000000..10fa748 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.reservation.dto.request; + +public record AddQueueRequest( + Long scheduleId, + Long userId +) { + +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java new file mode 100644 index 0000000..a22c5fd --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java @@ -0,0 +1,10 @@ +package org.example.siljeun.domain.reservation.dto.response; + +public record MyQueueInfoResponse( + Long scheduleId, + Long userId, + Long rank, + Long acceptedRank +) { + +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 7e49507..9c7f018 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -51,5 +51,6 @@ public void delete(Long reservationId) { () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); reservationRepository.delete(reservation); + // 좌석 상태 변경 } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java new file mode 100644 index 0000000..4f2f381 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -0,0 +1,43 @@ +package org.example.siljeun.domain.reservation.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class WaitingQueueService { + + private final StringRedisTemplate redisTemplate; + private final SimpMessagingTemplate messagingTemplate; + + // redis 연결 확인 + @PostConstruct + public void testRedisConnection() { + String pong = redisTemplate.getConnectionFactory().getConnection().ping(); + System.out.println("Redis 연결 상태: " + pong); + } + + // 예매 대기 시작 + public void addQueue(Long scheduleId, Long userId) { + long ttlMillis = 900000L; // 15분 + long acceptedRank = 1000L; + String key = "queue:schedule:" + scheduleId; + ZSetOperations zSet = redisTemplate.opsForZSet(); + + long expiredAt = System.currentTimeMillis() + ttlMillis; + zSet.add(key, String.valueOf(userId), + expiredAt); + + Long rank = zSet.rank(key, String.valueOf(userId)); + rank = (rank != null) ? rank + 1 : -1; + + String destination = "/topic/queue/" + scheduleId + "/" + userId; + MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, userId, rank, acceptedRank); + messagingTemplate.convertAndSend(destination, response); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java index bc9a52e..c3a7758 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java @@ -6,11 +6,9 @@ import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.reservation.service.ReservationService; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -@EnableScheduling @Component public class ReservationScheduler { diff --git a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java new file mode 100644 index 0000000..0dce27d --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java @@ -0,0 +1,25 @@ +package org.example.siljeun.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { // 추가 공부 필요 + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 272c2a3..24b6efb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,3 @@ spring.application.name=siljeun +spring.data.redis.host=${host} +spring.data.redis.port=${port} From 279b55561695646a9ce78e8ddd731ecd355ad73a Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Tue, 20 May 2025 20:52:46 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat=20:=20=EC=86=8C=EC=BC=93=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=81=8A=EA=B2=BC=EC=9D=84=20=EB=95=8C=20sorted=20?= =?UTF-8?q?set=EC=97=90=EC=84=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WaitingQueueController.java | 4 +- .../dto/request/AddQueueRequest.java | 2 +- .../dto/response/MyQueueInfoResponse.java | 2 +- .../service/WaitingQueueService.java | 12 ++--- .../global/config/WebSocketConfig.java | 10 +++- .../queueing/JwtHandShakeInterceptor.java | 50 +++++++++++++++++++ .../StompDisconnectEventListener.java | 30 +++++++++++ 7 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java create mode 100644 src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index d0dd94b..ac03446 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -15,7 +15,7 @@ public class WaitingQueueController { @MessageMapping("/addQueue") public void addQueue(AddQueueRequest request) { Long scheduleId = request.scheduleId(); - Long userId = request.userId(); - waitingQueueService.addQueue(scheduleId, userId); + String username = request.username(); + waitingQueueService.addQueue(scheduleId, username); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java index 10fa748..e382063 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java @@ -2,7 +2,7 @@ public record AddQueueRequest( Long scheduleId, - Long userId + String username ) { } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java index a22c5fd..cacf256 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java @@ -2,7 +2,7 @@ public record MyQueueInfoResponse( Long scheduleId, - Long userId, + String username, Long rank, Long acceptedRank ) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index 4f2f381..a00c51f 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -23,21 +23,21 @@ public void testRedisConnection() { } // 예매 대기 시작 - public void addQueue(Long scheduleId, Long userId) { + public void addQueue(Long scheduleId, String username) { long ttlMillis = 900000L; // 15분 long acceptedRank = 1000L; String key = "queue:schedule:" + scheduleId; ZSetOperations zSet = redisTemplate.opsForZSet(); long expiredAt = System.currentTimeMillis() + ttlMillis; - zSet.add(key, String.valueOf(userId), - expiredAt); + zSet.add(key, username, expiredAt); - Long rank = zSet.rank(key, String.valueOf(userId)); + Long rank = zSet.rank(key, username); rank = (rank != null) ? rank + 1 : -1; - String destination = "/topic/queue/" + scheduleId + "/" + userId; - MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, userId, rank, acceptedRank); + String destination = "/topic/queue/" + scheduleId + "/" + username; + MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, + acceptedRank); messagingTemplate.convertAndSend(destination, response); } } diff --git a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java index 0dce27d..180a146 100644 --- a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java +++ b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java @@ -1,5 +1,6 @@ package org.example.siljeun.global.config; +import org.example.siljeun.global.queueing.JwtHandShakeInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; @@ -10,9 +11,16 @@ @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final JwtHandShakeInterceptor jwtHandShakeInterceptor; + + public WebSocketConfig(JwtHandShakeInterceptor jwtHandShakeInterceptor) { + this.jwtHandShakeInterceptor = jwtHandShakeInterceptor; + } + @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { // 추가 공부 필요 + public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") + .addInterceptors(jwtHandShakeInterceptor) .setAllowedOriginPatterns("*") .withSockJS(); } diff --git a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java new file mode 100644 index 0000000..8318ece --- /dev/null +++ b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java @@ -0,0 +1,50 @@ +package org.example.siljeun.global.queueing; + +import java.util.Map; +import org.example.siljeun.global.jwt.JwtUtil; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +@Component +public class JwtHandShakeInterceptor implements HandshakeInterceptor { + + private final JwtUtil jwtUtil; + + public JwtHandShakeInterceptor(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + // 소켓 연결 시도 직전에 동작 + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) throws Exception { + + HttpHeaders headers = request.getHeaders(); + String bearer = headers.getFirst("Authorization"); + + if (bearer != null && bearer.startsWith(JwtUtil.BEARER_PREFIX)) { + String jwt = bearer.substring(JwtUtil.BEARER_PREFIX.length()); + String username = jwtUtil.getUsername(jwt); + attributes.put("username", username); + } + + String scheduleId = headers.getFirst("scheduleId"); + attributes.put("scheduleId", scheduleId); + + return true; + } + + // 소켓 연결된 직후 동작 + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + + } +} diff --git a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java new file mode 100644 index 0000000..f1e9dcb --- /dev/null +++ b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java @@ -0,0 +1,30 @@ +package org.example.siljeun.global.queueing; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompDisconnectEventListener { + + private final StringRedisTemplate redisTemplate; + + @EventListener + public void handleDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + + String username = (String) accessor.getSessionAttributes().get("username"); + Long scheduleId = (Long) accessor.getSessionAttributes().get("scheduleId"); + + if (username != null && scheduleId != null) { + redisTemplate.opsForZSet().remove("queue:schedule:" + scheduleId, username); + log.info("Disconnected and removed Schedule: " + scheduleId + " && User: " + username); + } + } +} From bbf9103befca13be2ee82d548d40218a36d0ac69 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 03:24:05 +0900 Subject: [PATCH 13/32] =?UTF-8?q?refactor=20:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 13 +++-- .../reservation/entity/Reservation.java | 12 +++-- .../exception/CustomException.java | 15 ++++++ .../reservation/exception/ErrorCode.java | 20 ++++++-- .../exception/ReservationCustomException.java | 14 ------ .../ReservationExceptionHandler.java | 10 ++-- .../service/ReservationService.java | 23 +++++---- .../service/WaitingQueueService.java | 50 ++++++++++++++++--- .../task/ReservationScheduler.java | 15 +++--- .../global/config/WebSocketConfig.java | 6 +-- .../example/siljeun/global/dto/Response.java | 21 -------- .../siljeun/global/dto/ResponseDto.java | 3 ++ .../queueing/JwtHandShakeInterceptor.java | 24 ++++++--- .../StompDisconnectEventListener.java | 9 ++-- src/main/resources/application.properties | 1 + 15 files changed, 141 insertions(+), 95 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java delete mode 100644 src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java delete mode 100644 src/main/java/org/example/siljeun/global/dto/Response.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index b4fec2c..171e528 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -3,11 +3,12 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.service.ReservationService; -import org.example.siljeun.global.dto.Response; +import org.example.siljeun.global.dto.ResponseDto; +import org.example.siljeun.global.security.CustomUserDetails; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -18,10 +19,12 @@ public class ReservationController { private final ReservationService reservationService; @PatchMapping("/reservations/{reservationId}/discount") - public ResponseEntity> updatePrice(@RequestAttribute Long userId, + public ResponseEntity> updatePrice( + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId, @RequestBody UpdatePriceRequest requestDto) { - reservationService.updatePrice(userId, reservationId, requestDto); - return ResponseEntity.ok(Response.from("예매 금액 변경 완료")); + String username = userDetails.getUsername(); + reservationService.updatePrice(username, reservationId, requestDto); + return ResponseEntity.ok(ResponseDto.success("예매 금액 변경 완료", null)); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index 2997975..f0f5086 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -1,5 +1,8 @@ package org.example.siljeun.domain.reservation.entity; +import static org.example.siljeun.domain.reservation.enums.ReservationStatus.COMPLETE; +import static org.example.siljeun.domain.reservation.enums.ReservationStatus.PENDING; + import io.micrometer.common.util.StringUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -63,7 +66,7 @@ public class Reservation { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime created_at; + private LocalDateTime createdAt; public Reservation(User user, SeatScheduleInfo seatScheduleInfo) { this.user = user; @@ -71,11 +74,12 @@ public Reservation(User user, SeatScheduleInfo seatScheduleInfo) { this.price = seatScheduleInfo.getPrice(); this.ticketReceipt = TicketReceipt.PICKUP; this.discount = Discount.GENERAL; - this.status = ReservationStatus.PENDING; + this.status = PENDING; } - public void updateReservationStatus() { - this.status = ReservationStatus.COMPLETE; + public void updateReservationStatus(Reservation reservation) { + this.status = (reservation.status == PENDING) ? + COMPLETE : PENDING; } public void updateTicketPrice(UpdatePriceRequest dto) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java new file mode 100644 index 0000000..6549d4c --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java @@ -0,0 +1,15 @@ +package org.example.siljeun.domain.reservation.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +public class CustomException extends RuntimeException { + + @Getter + private HttpStatus errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode.getCode(); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java index aa51b6a..b56f607 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -1,16 +1,30 @@ package org.example.siljeun.domain.reservation.exception; import lombok.Getter; +import org.springframework.http.HttpStatus; @Getter public enum ErrorCode { + // reservation NOT_FOUND_RESERVATION(404, "예매정보가 존재하지 않습니다."), - INVALID_RESERVATION_USER(400, "예매정보가 일치하지 않습니다."); + INVALID_RESERVATION_USER(400, "예매정보가 일치하지 않습니다."), - private int code; + // user + NOT_FOUND_USER(404, "유저정보가 존재하지 않습니다."), + + // schedule + MISSING_HEADER(400, "필수 헤더값이 누락되었습니다."), + + // jwt + UNAUTHORIZED(401, "토큰이 유효하지 않습니다."), + + // queue + QUEUE_INSERT_FAIL(500, "대기열 등록을 실패했습니다."); + + private HttpStatus code; private String message; - ErrorCode(int errorCode, String message) { + ErrorCode(int code, String message) { } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java deleted file mode 100644 index e1db58e..0000000 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationCustomException.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.example.siljeun.domain.reservation.exception; - -import lombok.Getter; - -public class ReservationCustomException extends RuntimeException { - - @Getter - private int errorCode; - - public ReservationCustomException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode.getCode(); - } -} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java index 3f9d6e6..13dc24e 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java @@ -1,6 +1,6 @@ package org.example.siljeun.domain.reservation.exception; -import org.example.siljeun.global.dto.Response; +import org.example.siljeun.global.dto.ResponseDto; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -8,10 +8,10 @@ @RestControllerAdvice public class ReservationExceptionHandler { - @ExceptionHandler - public ResponseEntity> ReservationExceptionHandler( - ReservationCustomException e) { + @ExceptionHandler(CustomException.class) + public ResponseEntity> reservationExceptionHandler( + CustomException e) { return ResponseEntity.status(e.getErrorCode()) - .body(Response.of(false, e.getMessage())); + .body(ResponseDto.fail(e.getMessage())); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 9c7f018..05246d8 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -4,7 +4,7 @@ import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.entity.Reservation; import org.example.siljeun.domain.reservation.exception.ErrorCode; -import org.example.siljeun.domain.reservation.exception.ReservationCustomException; +import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; @@ -18,28 +18,31 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; + private final WaitingQueueService waitingQueueService; // 좌석 도메인에서 호출할 메서드 - 예매 정보 저장 @Transactional public void save(User user, SeatScheduleInfo seatScheduleInfo) { Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); + waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername()); } - // 결제 도메인에서 호출할 메서드 - 결제완료 처리 + // 결제 도메인에서 호출할 메서드 - 결제완료 or 결제취소 처리 @Transactional public void updateReservationStatus(Reservation reservation) { - reservation.updateReservationStatus(); + reservation.updateReservationStatus(reservation); } @Transactional - public void updatePrice(Long userId, Long reservationId, UpdatePriceRequest requestDto) { - User user = userRepository.findById(userId).orElseThrow(RuntimeException::new); - Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( - () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); + public void updatePrice(String username, Long reservationId, UpdatePriceRequest requestDto) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); + Reservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_RESERVATION)); if (reservation.getUser() != user) { - throw new ReservationCustomException(ErrorCode.INVALID_RESERVATION_USER); + throw new CustomException(ErrorCode.INVALID_RESERVATION_USER); } reservation.updateTicketPrice(requestDto); @@ -48,9 +51,9 @@ public void updatePrice(Long userId, Long reservationId, UpdatePriceRequest requ @Transactional public void delete(Long reservationId) { Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( - () -> new ReservationCustomException(ErrorCode.NOT_FOUND_RESERVATION)); + () -> new CustomException(ErrorCode.NOT_FOUND_RESERVATION)); reservationRepository.delete(reservation); - // 좌석 상태 변경 + // Todo : 좌석 상태 변경 } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index a00c51f..159fe39 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -2,12 +2,16 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; +import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class WaitingQueueService { @@ -15,24 +19,52 @@ public class WaitingQueueService { private final StringRedisTemplate redisTemplate; private final SimpMessagingTemplate messagingTemplate; + private static final long ttlMillis = 900000L; // ttl 15분 + private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명 + private static final String prefixKey = "queue:schedule:"; + // redis 연결 확인 @PostConstruct public void testRedisConnection() { String pong = redisTemplate.getConnectionFactory().getConnection().ping(); - System.out.println("Redis 연결 상태: " + pong); + log.info("Redis 연결 상태: {}", pong); } // 예매 대기 시작 public void addQueue(Long scheduleId, String username) { - long ttlMillis = 900000L; // 15분 - long acceptedRank = 1000L; - String key = "queue:schedule:" + scheduleId; - ZSetOperations zSet = redisTemplate.opsForZSet(); + String key = prefixKey + scheduleId; long expiredAt = System.currentTimeMillis() + ttlMillis; - zSet.add(key, username, expiredAt); + ZSetOperations zSet = redisTemplate.opsForZSet(); + + if (zSet.score(key, username) == null) { + zSet.add(key, username, expiredAt); + } Long rank = zSet.rank(key, username); + if (rank == null) { + throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL); + } + rank = rank + 1; + + String destination = "/topic/queue/" + scheduleId + "/" + username; + MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, + acceptedRank); + messagingTemplate.convertAndSend(destination, response); + } + + // 기존 유저가 좌석 선택 완료 or 소켓 연결 종료하면 대기열에서 삭제 + public void deleteAtQueue(Long scheduleId, String username) { + redisTemplate.opsForZSet().remove(prefixKey + scheduleId, username); + log.info("Disconnected and removed Schedule: {}, User: {}", scheduleId, username); + + // Todo : 스케줄러나 Lua 스크립트 고려 + // TTL 만료된 데이터 삭제 + redisTemplate.opsForZSet() + .removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis()); + + // rank() 재실행해서 변경된 대기번호 클라이언트에 전송 + Long rank = redisTemplate.opsForZSet().rank(prefixKey + scheduleId, username); rank = (rank != null) ? rank + 1 : -1; String destination = "/topic/queue/" + scheduleId + "/" + username; @@ -40,4 +72,10 @@ public void addQueue(Long scheduleId, String username) { acceptedRank); messagingTemplate.convertAndSend(destination, response); } + + // sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인 + public boolean checkQueue(Long scheduleId, String username) { + boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null; + return exists; + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java index c3a7758..816c494 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java @@ -2,32 +2,29 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.reservation.service.ReservationService; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class ReservationScheduler { private final ReservationRepository reservationRepository; private final ReservationService reservationService; - - @Autowired - public ReservationScheduler(ReservationRepository reservationRepository, - ReservationService reservationService) { - this.reservationRepository = reservationRepository; - this.reservationService = reservationService; - } + private final StringRedisTemplate redisTemplate; @Scheduled(fixedRate = 60000) // 1분마다 실행 public void returnSeat() { LocalDateTime now = LocalDateTime.now(); + // Todo : 성능 개선 reservationRepository.findByStatus(ReservationStatus.PENDING).stream() - .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreated_at(), now) >= 7) + .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreatedAt(), now) >= 7) .forEach(reservation -> reservationService.delete(reservation.getId())); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java index 180a146..2d028e2 100644 --- a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java +++ b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java @@ -1,5 +1,6 @@ package org.example.siljeun.global.config; +import lombok.RequiredArgsConstructor; import org.example.siljeun.global.queueing.JwtHandShakeInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -8,15 +9,12 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @Configuration +@RequiredArgsConstructor @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final JwtHandShakeInterceptor jwtHandShakeInterceptor; - public WebSocketConfig(JwtHandShakeInterceptor jwtHandShakeInterceptor) { - this.jwtHandShakeInterceptor = jwtHandShakeInterceptor; - } - @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") diff --git a/src/main/java/org/example/siljeun/global/dto/Response.java b/src/main/java/org/example/siljeun/global/dto/Response.java deleted file mode 100644 index da3caca..0000000 --- a/src/main/java/org/example/siljeun/global/dto/Response.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.example.siljeun.global.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; - -public record Response( - - boolean success, - String message, - @JsonInclude(Include.NON_NULL) - T data -) { - - public static Response from(String message) { - return new Response<>(true, message, null); - } - - public static Response of(boolean success, String message) { - return new Response<>(success, message, null); - } -} diff --git a/src/main/java/org/example/siljeun/global/dto/ResponseDto.java b/src/main/java/org/example/siljeun/global/dto/ResponseDto.java index fdea9c0..5ad4e8d 100644 --- a/src/main/java/org/example/siljeun/global/dto/ResponseDto.java +++ b/src/main/java/org/example/siljeun/global/dto/ResponseDto.java @@ -1,5 +1,7 @@ package org.example.siljeun.global.dto; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import lombok.AllArgsConstructor; @AllArgsConstructor @@ -7,6 +9,7 @@ public class ResponseDto { private boolean success; private String message; + @JsonInclude(Include.NON_NULL) private T data; public static ResponseDto success(String message, T data) { diff --git a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java index 8318ece..98862ef 100644 --- a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java +++ b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java @@ -1,6 +1,10 @@ package org.example.siljeun.global.queueing; +import io.micrometer.common.util.StringUtils; import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.exception.ErrorCode; +import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.global.jwt.JwtUtil; import org.springframework.http.HttpHeaders; import org.springframework.http.server.ServerHttpRequest; @@ -10,14 +14,11 @@ import org.springframework.web.socket.server.HandshakeInterceptor; @Component +@RequiredArgsConstructor public class JwtHandShakeInterceptor implements HandshakeInterceptor { private final JwtUtil jwtUtil; - public JwtHandShakeInterceptor(JwtUtil jwtUtil) { - this.jwtUtil = jwtUtil; - } - // 소켓 연결 시도 직전에 동작 @Override public boolean beforeHandshake( @@ -29,13 +30,20 @@ public boolean beforeHandshake( HttpHeaders headers = request.getHeaders(); String bearer = headers.getFirst("Authorization"); - if (bearer != null && bearer.startsWith(JwtUtil.BEARER_PREFIX)) { - String jwt = bearer.substring(JwtUtil.BEARER_PREFIX.length()); - String username = jwtUtil.getUsername(jwt); - attributes.put("username", username); + if (bearer == null || !bearer.startsWith(JwtUtil.BEARER_PREFIX)) { + throw new CustomException(ErrorCode.UNAUTHORIZED); } + String jwt = bearer.substring(JwtUtil.BEARER_PREFIX.length()); + jwtUtil.validateToken(jwt); + String username = jwtUtil.getUsername(jwt); + attributes.put("username", username); + + // Todo : STOMP 테스트 실패 여부에 따라 헤더가 아니라 uri 추출 방식으로 변경 String scheduleId = headers.getFirst("scheduleId"); + if (StringUtils.isBlank(scheduleId)) { + throw new CustomException(ErrorCode.MISSING_HEADER); + } attributes.put("scheduleId", scheduleId); return true; diff --git a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java index f1e9dcb..a921fe3 100644 --- a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java +++ b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java @@ -1,19 +1,17 @@ package org.example.siljeun.global.queueing; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; import org.springframework.context.event.EventListener; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionDisconnectEvent; -@Slf4j @Component @RequiredArgsConstructor public class StompDisconnectEventListener { - private final StringRedisTemplate redisTemplate; + private final WaitingQueueService waitingQueueService; @EventListener public void handleDisconnect(SessionDisconnectEvent event) { @@ -23,8 +21,7 @@ public void handleDisconnect(SessionDisconnectEvent event) { Long scheduleId = (Long) accessor.getSessionAttributes().get("scheduleId"); if (username != null && scheduleId != null) { - redisTemplate.opsForZSet().remove("queue:schedule:" + scheduleId, username); - log.info("Disconnected and removed Schedule: " + scheduleId + " && User: " + username); + waitingQueueService.deleteAtQueue(scheduleId, username); } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 24b6efb..e15cf33 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,4 @@ spring.application.name=siljeun spring.data.redis.host=${host} spring.data.redis.port=${port} +jwt.secret.key=${JWT_SECRET_KEY} From c5d8529b9c3f8dc776a6e2083a8faafa7737fc14 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 11:09:01 +0900 Subject: [PATCH 14/32] =?UTF-8?q?feat=20:=20=EC=98=88=EB=A7=A4=EC=B7=A8?= =?UTF-8?q?=EC=86=8C,=20=EC=98=88=EB=A7=A4=EC=A1=B0=ED=9A=8C=20api=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 28 ++++++++++-- .../controller/WaitingQueueController.java | 3 +- .../dto/request/AddQueueRequest.java | 4 ++ .../dto/request/UpdatePriceRequest.java | 6 +++ .../dto/response/ReservationInfoResponse.java | 43 +++++++++++++++++++ .../ReservationExceptionHandler.java | 13 +++++- .../ReservationScheduler.java | 6 +-- .../service/ReservationService.java | 28 +++++++++++- .../reservation/validation/EnumValidator.java | 26 +++++++++++ .../reservation/validation/ValidEnum.java | 22 ++++++++++ 10 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java rename src/main/java/org/example/siljeun/domain/reservation/{task => scheduler}/ReservationScheduler.java (79%) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/validation/EnumValidator.java create mode 100644 src/main/java/org/example/siljeun/domain/reservation/validation/ValidEnum.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 171e528..d934107 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -1,30 +1,52 @@ package org.example.siljeun.domain.reservation.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; +import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.service.ReservationService; import org.example.siljeun.global.dto.ResponseDto; import org.example.siljeun.global.security.CustomUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor +@RequestMapping("/reservations") public class ReservationController { private final ReservationService reservationService; - @PatchMapping("/reservations/{reservationId}/discount") - public ResponseEntity> updatePrice( + @PatchMapping("/{reservationId}/discount") + public ResponseEntity> updatePrice( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId, - @RequestBody UpdatePriceRequest requestDto) { + @RequestBody @Valid UpdatePriceRequest requestDto) { String username = userDetails.getUsername(); reservationService.updatePrice(username, reservationId, requestDto); return ResponseEntity.ok(ResponseDto.success("예매 금액 변경 완료", null)); } + + @DeleteMapping("/{reservationId}") + public ResponseEntity> delete( + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId) { + String username = userDetails.getUsername(); + reservationService.delete(username, reservationId); + return ResponseEntity.ok(ResponseDto.success("예매 취소 완료", null)); + } + + @GetMapping("/{reservationId}") + public ResponseEntity> findById( + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId) { + String username = userDetails.getUsername(); + ReservationInfoResponse dto = reservationService.findById(username, reservationId); + return ResponseEntity.ok(ResponseDto.success("예매 조회 성공", dto)); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index ac03446..592a20e 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -1,5 +1,6 @@ package org.example.siljeun.domain.reservation.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.AddQueueRequest; import org.example.siljeun.domain.reservation.service.WaitingQueueService; @@ -13,7 +14,7 @@ public class WaitingQueueController { private final WaitingQueueService waitingQueueService; @MessageMapping("/addQueue") - public void addQueue(AddQueueRequest request) { + public void addQueue(@Valid AddQueueRequest request) { Long scheduleId = request.scheduleId(); String username = request.username(); waitingQueueService.addQueue(scheduleId, username); diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java index e382063..c3472a1 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java @@ -1,7 +1,11 @@ package org.example.siljeun.domain.reservation.dto.request; +import jakarta.validation.constraints.NotBlank; + public record AddQueueRequest( + @NotBlank Long scheduleId, + @NotBlank String username ) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java index d40c1a7..cd6e643 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/UpdatePriceRequest.java @@ -1,7 +1,13 @@ package org.example.siljeun.domain.reservation.dto.request; +import org.example.siljeun.domain.reservation.enums.Discount; +import org.example.siljeun.domain.reservation.enums.TicketReceipt; +import org.example.siljeun.domain.reservation.validation.ValidEnum; + public record UpdatePriceRequest( + @ValidEnum(enumClass = TicketReceipt.class) String ticketReceipt, + @ValidEnum(enumClass = Discount.class) String discount ) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java new file mode 100644 index 0000000..ec5ce02 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java @@ -0,0 +1,43 @@ +package org.example.siljeun.domain.reservation.dto.response; + +import java.time.LocalDateTime; +import org.example.siljeun.domain.reservation.entity.Reservation; +import org.example.siljeun.domain.reservation.enums.Discount; +import org.example.siljeun.domain.reservation.enums.TicketReceipt; + +public record ReservationInfoResponse( + Long id, + Long userId, + Long concertId, + String concertTitle, + String venueName, + LocalDateTime startTime, + String seatGrade, + String seatSection, + String seatRow, + String seatNumber, + TicketReceipt ticketReceipt, + int price, + Discount discount, + LocalDateTime cancelDeadline +) { + + public static ReservationInfoResponse from(Reservation reservation) { + return new ReservationInfoResponse( + reservation.getId(), + reservation.getUser().getId(), + reservation.getSeatScheduleInfo().getSchedule().getConcert().getId(), + reservation.getSeatScheduleInfo().getSchedule().getConcert().getTitle(), + reservation.getSeatScheduleInfo().getSeat().getVenue().getName(), + reservation.getSeatScheduleInfo().getSchedule().getStartTime(), + reservation.getSeatScheduleInfo().getGrade(), + reservation.getSeatScheduleInfo().getSeat().getSection(), + reservation.getSeatScheduleInfo().getSeat().getRow(), + reservation.getSeatScheduleInfo().getSeat().getNumber(), + reservation.getTicketReceipt(), + reservation.getPrice(), + reservation.getDiscount(), + reservation.getSeatScheduleInfo().getSchedule().getStartTime().minusDays(1) + ); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java index 13dc24e..2790264 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java @@ -1,7 +1,11 @@ package org.example.siljeun.domain.reservation.exception; import org.example.siljeun.global.dto.ResponseDto; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -9,9 +13,16 @@ public class ReservationExceptionHandler { @ExceptionHandler(CustomException.class) - public ResponseEntity> reservationExceptionHandler( + public ResponseEntity> reservationExceptionHandler( CustomException e) { return ResponseEntity.status(e.getErrorCode()) .body(ResponseDto.fail(e.getMessage())); } + + @ExceptionHandler({MethodArgumentNotValidException.class, MessageConversionException.class, + MessagingException.class}) + public ResponseEntity> validationExceptionHandler(Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ResponseDto.fail(e.getMessage())); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java similarity index 79% rename from src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java rename to src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java index 816c494..4e51d5a 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/task/ReservationScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java @@ -1,4 +1,4 @@ -package org.example.siljeun.domain.reservation.task; +package org.example.siljeun.domain.reservation.scheduler; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -6,7 +6,6 @@ import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.reservation.service.ReservationService; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -16,7 +15,6 @@ public class ReservationScheduler { private final ReservationRepository reservationRepository; private final ReservationService reservationService; - private final StringRedisTemplate redisTemplate; @Scheduled(fixedRate = 60000) // 1분마다 실행 public void returnSeat() { @@ -25,6 +23,6 @@ public void returnSeat() { // Todo : 성능 개선 reservationRepository.findByStatus(ReservationStatus.PENDING).stream() .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreatedAt(), now) >= 7) - .forEach(reservation -> reservationService.delete(reservation.getId())); + .forEach(reservationService::deleteByScheduling); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 05246d8..8233842 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.reservation.service; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.entity.Reservation; import org.example.siljeun.domain.reservation.exception.ErrorCode; @@ -49,11 +50,36 @@ public void updatePrice(String username, Long reservationId, UpdatePriceRequest } @Transactional - public void delete(Long reservationId) { + public void delete(String username, Long reservationId) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( () -> new CustomException(ErrorCode.NOT_FOUND_RESERVATION)); + if (reservation.getUser() != user) { + throw new CustomException(ErrorCode.INVALID_RESERVATION_USER); + } + + reservationRepository.delete(reservation); + // Todo : 좌석 상태 변경 + } + + @Transactional + public void deleteByScheduling(Reservation reservation) { reservationRepository.delete(reservation); // Todo : 좌석 상태 변경 } + + public ReservationInfoResponse findById(String username, Long reservationId) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow( + () -> new CustomException(ErrorCode.NOT_FOUND_RESERVATION)); + + if (reservation.getUser() != user) { + throw new CustomException(ErrorCode.INVALID_RESERVATION_USER); + } + + return ReservationInfoResponse.from(reservation); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/validation/EnumValidator.java b/src/main/java/org/example/siljeun/domain/reservation/validation/EnumValidator.java new file mode 100644 index 0000000..46aab51 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/validation/EnumValidator.java @@ -0,0 +1,26 @@ +package org.example.siljeun.domain.reservation.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public class EnumValidator implements ConstraintValidator { + + private Set values; + + @Override + public void initialize(ValidEnum annotation) { + Class> enumClass = annotation.enumClass(); + + values = Arrays.stream(enumClass.getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toSet()); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { + return value == null || values.contains(value.toUpperCase()); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/validation/ValidEnum.java b/src/main/java/org/example/siljeun/domain/reservation/validation/ValidEnum.java new file mode 100644 index 0000000..3a719f6 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/validation/ValidEnum.java @@ -0,0 +1,22 @@ +package org.example.siljeun.domain.reservation.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = EnumValidator.class) +public @interface ValidEnum { + + String message() default "입력값이 유효하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + Class> enumClass(); +} From d508180d30613c531fae02980b91fc11d4f3e501 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 14:44:36 +0900 Subject: [PATCH 15/32] =?UTF-8?q?refactor=20:=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EC=85=89=ED=84=B0=EC=97=90=EC=84=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EC=B6=9C=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/ReservationScheduler.java | 1 - .../service/WaitingQueueService.java | 1 - .../queueing/JwtHandShakeInterceptor.java | 17 +++++++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java index 4e51d5a..32a7152 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java @@ -20,7 +20,6 @@ public class ReservationScheduler { public void returnSeat() { LocalDateTime now = LocalDateTime.now(); - // Todo : 성능 개선 reservationRepository.findByStatus(ReservationStatus.PENDING).stream() .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreatedAt(), now) >= 7) .forEach(reservationService::deleteByScheduling); diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index 159fe39..fa44044 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -58,7 +58,6 @@ public void deleteAtQueue(Long scheduleId, String username) { redisTemplate.opsForZSet().remove(prefixKey + scheduleId, username); log.info("Disconnected and removed Schedule: {}, User: {}", scheduleId, username); - // Todo : 스케줄러나 Lua 스크립트 고려 // TTL 만료된 데이터 삭제 redisTemplate.opsForZSet() .removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis()); diff --git a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java index 98862ef..3da9f18 100644 --- a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java +++ b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java @@ -1,23 +1,28 @@ package org.example.siljeun.global.queueing; import io.micrometer.common.util.StringUtils; +import java.net.URI; import java.util.Map; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.global.jwt.JwtUtil; import org.springframework.http.HttpHeaders; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; @Component @RequiredArgsConstructor public class JwtHandShakeInterceptor implements HandshakeInterceptor { private final JwtUtil jwtUtil; + private final ScheduleRepository scheduleRepository; // 소켓 연결 시도 직전에 동작 @Override @@ -39,9 +44,13 @@ public boolean beforeHandshake( String username = jwtUtil.getUsername(jwt); attributes.put("username", username); - // Todo : STOMP 테스트 실패 여부에 따라 헤더가 아니라 uri 추출 방식으로 변경 - String scheduleId = headers.getFirst("scheduleId"); - if (StringUtils.isBlank(scheduleId)) { + URI uri = request.getURI(); + MultiValueMap params = UriComponentsBuilder.fromUri(uri).build() + .getQueryParams(); + String scheduleId = params.getFirst("scheduleId"); + + if (StringUtils.isBlank(scheduleId) || !scheduleRepository.existsById( + Long.valueOf(scheduleId))) { throw new CustomException(ErrorCode.MISSING_HEADER); } attributes.put("scheduleId", scheduleId); From ccc5362b2ca700293e67873ce84dd43c07473dbb Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 17:50:45 +0900 Subject: [PATCH 16/32] chore : .gitignore update --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c2065bc..c009037 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +application-test.yml +application.yml ### STS ### .apt_generated From 4dd97caabfcfe508469ef6edbffd213bbd23575d Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 18:01:49 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat=20:=20=EC=86=8C=EC=BC=93=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.properties | 4 - .../org/example/siljeun/WebSocketTest.java | 86 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) delete mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/org/example/siljeun/WebSocketTest.java diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e15cf33..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,4 +0,0 @@ -spring.application.name=siljeun -spring.data.redis.host=${host} -spring.data.redis.port=${port} -jwt.secret.key=${JWT_SECRET_KEY} diff --git a/src/test/java/org/example/siljeun/WebSocketTest.java b/src/test/java/org/example/siljeun/WebSocketTest.java new file mode 100644 index 0000000..8240694 --- /dev/null +++ b/src/test/java/org/example/siljeun/WebSocketTest.java @@ -0,0 +1,86 @@ +package org.example.siljeun; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; +import org.example.siljeun.domain.concert.entity.Concert; +import org.example.siljeun.domain.concert.entity.ConcertCategory; +import org.example.siljeun.domain.concert.repository.ConcertRepository; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.venue.entity.Venue; +import org.example.siljeun.domain.venue.repository.VenueRepository; +import org.example.siljeun.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class WebSocketTest { + + @LocalServerPort + private int port; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + private JwtUtil jwtUtil; + + private String validToken; + + private Schedule savedSchedule; + + @BeforeEach + void setup() { + Concert savedConcert; + Venue savedVenue; + + savedVenue = venueRepository.save(new Venue("name", "location", 1000)); + savedConcert = concertRepository.save( + new Concert("title", "description", ConcertCategory.CONCERT, savedVenue, 1)); + savedSchedule = scheduleRepository.save( + new Schedule(savedConcert, LocalDateTime.now(), LocalDateTime.now())); + validToken = jwtUtil.createToken("testUser"); + } + + @Test + void socket_connection_test() throws Exception { + + WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + URI uri = new URI("ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId()); + + WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); + StompHeaders stompHeaders = new StompHeaders(); + webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); + + StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, + new StompSessionHandlerAdapter() { + } + ).get(5, TimeUnit.SECONDS); + + assertTrue(session.isConnected()); + } +} From d30221572d211e7029c684aee7ed06dcc9cffa9d Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 20:23:52 +0900 Subject: [PATCH 18/32] =?UTF-8?q?chore=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WaitingQueueController.java | 7 ++++--- .../siljeun/global/config/SecurityConfig.java | 2 +- .../global/queueing/JwtHandShakeInterceptor.java | 7 +++++-- .../java/org/example/siljeun/WebSocketTest.java | 15 +++++++++++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index 592a20e..2a2132b 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -15,8 +15,9 @@ public class WaitingQueueController { @MessageMapping("/addQueue") public void addQueue(@Valid AddQueueRequest request) { - Long scheduleId = request.scheduleId(); - String username = request.username(); - waitingQueueService.addQueue(scheduleId, username); +// Long scheduleId = request.scheduleId(); +// String username = request.username(); +// waitingQueueService.addQueue(scheduleId, username); + System.out.println("연결 성공"); } } diff --git a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java index 8976d6a..a097bd3 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() + .requestMatchers("/auth/**", "/oauth2/**", "/login/**", "/ws/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 diff --git a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java index 3da9f18..abd049f 100644 --- a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java +++ b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java @@ -7,7 +7,7 @@ import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.global.security.JwtUtil; import org.springframework.http.HttpHeaders; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; @@ -40,7 +40,10 @@ public boolean beforeHandshake( } String jwt = bearer.substring(JwtUtil.BEARER_PREFIX.length()); - jwtUtil.validateToken(jwt); + if (!jwtUtil.validateToken(jwt)) { + return false; + } + String username = jwtUtil.getUsername(jwt); attributes.put("username", username); diff --git a/src/test/java/org/example/siljeun/WebSocketTest.java b/src/test/java/org/example/siljeun/WebSocketTest.java index 8240694..271e66d 100644 --- a/src/test/java/org/example/siljeun/WebSocketTest.java +++ b/src/test/java/org/example/siljeun/WebSocketTest.java @@ -4,6 +4,7 @@ import java.net.URI; import java.time.LocalDateTime; +import java.util.List; import java.util.concurrent.TimeUnit; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; @@ -12,7 +13,7 @@ import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.venue.entity.Venue; import org.example.siljeun.domain.venue.repository.VenueRepository; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.global.security.JwtUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,9 @@ import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.sockjs.client.SockJsClient; +import org.springframework.web.socket.sockjs.client.Transport; +import org.springframework.web.socket.sockjs.client.WebSocketTransport; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @@ -67,19 +71,22 @@ void setup() { @Test void socket_connection_test() throws Exception { - WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); + SockJsClient sockJsClient = new SockJsClient(transports); + WebSocketStompClient stompClient = new WebSocketStompClient(sockJsClient); stompClient.setMessageConverter(new MappingJackson2MessageConverter()); URI uri = new URI("ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId()); WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); - StompHeaders stompHeaders = new StompHeaders(); webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); + StompHeaders stompHeaders = new StompHeaders(); + StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, new StompSessionHandlerAdapter() { } - ).get(5, TimeUnit.SECONDS); + ).get(100, TimeUnit.SECONDS); assertTrue(session.isConnected()); } From 390cc9b615298866706bcfd4553b9c3586c633da Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 04:03:32 +0900 Subject: [PATCH 19/32] =?UTF-8?q?fix=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20SecurityConfig=20oauth=20=EC=84=A4=EC=A0=95=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../siljeun/global/config/SecurityConfig.java | 14 +++++---- .../global/config/WebSocketConfig.java | 3 +- .../queueing/JwtHandShakeInterceptor.java | 29 +++++++------------ .../StompDisconnectEventListener.java | 2 +- .../org/example/siljeun/WebSocketTest.java | 14 ++++----- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java index a097bd3..9f3ef6e 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -6,6 +6,7 @@ import org.example.siljeun.global.security.JwtAuthenticationFilter; import org.example.siljeun.global.security.JwtUtil; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -14,6 +15,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -30,14 +32,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth2/**", "/login/**", "/ws/**").permitAll() + .requestMatchers("/auth/**", "/oauth2/**", "/login/**", "/ws/**", "/ws").permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .successHandler(customOAuth2SuccessHandler) - .defaultSuccessUrl("/auth/oauth2/success", true) - .failureUrl("/auth/oauth2/failure") - ) +// .oauth2Login(oauth2 -> oauth2 +// .successHandler(customOAuth2SuccessHandler) +// .defaultSuccessUrl("/auth/oauth2/success", true) +// .failureUrl("/auth/oauth2/failure") +// ) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java index 2d028e2..fb76071 100644 --- a/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java +++ b/src/main/java/org/example/siljeun/global/config/WebSocketConfig.java @@ -19,8 +19,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .addInterceptors(jwtHandShakeInterceptor) - .setAllowedOriginPatterns("*") - .withSockJS(); + .setAllowedOriginPatterns("*"); } @Override diff --git a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java index abd049f..f86622d 100644 --- a/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java +++ b/src/main/java/org/example/siljeun/global/queueing/JwtHandShakeInterceptor.java @@ -8,7 +8,6 @@ import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.global.security.JwtUtil; -import org.springframework.http.HttpHeaders; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.stereotype.Component; @@ -24,7 +23,6 @@ public class JwtHandShakeInterceptor implements HandshakeInterceptor { private final JwtUtil jwtUtil; private final ScheduleRepository scheduleRepository; - // 소켓 연결 시도 직전에 동작 @Override public boolean beforeHandshake( ServerHttpRequest request, @@ -32,36 +30,31 @@ public boolean beforeHandshake( WebSocketHandler wsHandler, Map attributes) throws Exception { - HttpHeaders headers = request.getHeaders(); - String bearer = headers.getFirst("Authorization"); - - if (bearer == null || !bearer.startsWith(JwtUtil.BEARER_PREFIX)) { - throw new CustomException(ErrorCode.UNAUTHORIZED); - } - - String jwt = bearer.substring(JwtUtil.BEARER_PREFIX.length()); - if (!jwtUtil.validateToken(jwt)) { - return false; - } - - String username = jwtUtil.getUsername(jwt); - attributes.put("username", username); - URI uri = request.getURI(); MultiValueMap params = UriComponentsBuilder.fromUri(uri).build() .getQueryParams(); + String scheduleId = params.getFirst("scheduleId"); if (StringUtils.isBlank(scheduleId) || !scheduleRepository.existsById( Long.valueOf(scheduleId))) { throw new CustomException(ErrorCode.MISSING_HEADER); } + attributes.put("scheduleId", scheduleId); + String jwt = params.getFirst("token"); + + if (!jwtUtil.validateToken(jwt)) { + return false; + } + + String username = jwtUtil.getUsername(jwt); + attributes.put("username", username); + return true; } - // 소켓 연결된 직후 동작 @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { diff --git a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java index a921fe3..4a37340 100644 --- a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java +++ b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java @@ -18,7 +18,7 @@ public void handleDisconnect(SessionDisconnectEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) accessor.getSessionAttributes().get("username"); - Long scheduleId = (Long) accessor.getSessionAttributes().get("scheduleId"); + Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId")); if (username != null && scheduleId != null) { waitingQueueService.deleteAtQueue(scheduleId, username); diff --git a/src/test/java/org/example/siljeun/WebSocketTest.java b/src/test/java/org/example/siljeun/WebSocketTest.java index 271e66d..2ea049c 100644 --- a/src/test/java/org/example/siljeun/WebSocketTest.java +++ b/src/test/java/org/example/siljeun/WebSocketTest.java @@ -4,7 +4,6 @@ import java.net.URI; import java.time.LocalDateTime; -import java.util.List; import java.util.concurrent.TimeUnit; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; @@ -28,9 +27,6 @@ import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; -import org.springframework.web.socket.sockjs.client.SockJsClient; -import org.springframework.web.socket.sockjs.client.Transport; -import org.springframework.web.socket.sockjs.client.WebSocketTransport; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @@ -71,12 +67,12 @@ void setup() { @Test void socket_connection_test() throws Exception { - List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); - SockJsClient sockJsClient = new SockJsClient(transports); - WebSocketStompClient stompClient = new WebSocketStompClient(sockJsClient); + WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); stompClient.setMessageConverter(new MappingJackson2MessageConverter()); - URI uri = new URI("ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId()); + URI uri = new URI( + "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId() + "&token=" + + validToken); WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); @@ -86,7 +82,7 @@ void socket_connection_test() throws Exception { StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, new StompSessionHandlerAdapter() { } - ).get(100, TimeUnit.SECONDS); + ).get(5, TimeUnit.SECONDS); assertTrue(session.isConnected()); } From 3454fbab6266f0726f16d8b3ee23a7c36cd581e2 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 04:10:15 +0900 Subject: [PATCH 20/32] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/controller/WaitingQueueController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index 2a2132b..fb907ee 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -15,9 +15,9 @@ public class WaitingQueueController { @MessageMapping("/addQueue") public void addQueue(@Valid AddQueueRequest request) { -// Long scheduleId = request.scheduleId(); -// String username = request.username(); -// waitingQueueService.addQueue(scheduleId, username); + Long scheduleId = request.scheduleId(); + String username = request.username(); + waitingQueueService.addQueue(scheduleId, username); System.out.println("연결 성공"); } } From e28ede09d34ebbaeeaa24be865e1f199be798733 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 10:08:17 +0900 Subject: [PATCH 21/32] =?UTF-8?q?chore=20:=20dev-ci.yml=EC=97=90=20redis?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index d63fd47..c278425 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -8,6 +8,15 @@ jobs: build-and-test: runs-on: ubuntu-latest + services: + redis: + image: redis:7.2-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout Repository uses: actions/checkout@v3 @@ -27,6 +36,8 @@ jobs: echo "toss.client-key=${{ secrets.TOSS_CLIENT_KEY }}" >> ./src/main/resources/application.properties echo "toss.secret-key=${{ secrets.TOSS_SECRET_KEY }}" >> ./src/main/resources/application.properties echo "jwt.secret.key=${{ secrets.JWT_SECRET_KEY }}" >> ./src/main/resources/application.properties + echo "spring.redis.host=localhost" >> ./src/main/resources/application.properties + echo "spring.redis.port=6379" >> ./src/main/resources/application.properties - name: Build Project run: ./gradlew clean build From a45935b096e12db18c5a0ad2488a29bb7aad53fe Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 10:46:16 +0900 Subject: [PATCH 22/32] =?UTF-8?q?chore=20:=20redis=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20=ED=98=B8=EC=8A=A4=ED=8A=B8=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index c278425..64d481b 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -10,7 +10,7 @@ jobs: services: redis: - image: redis:7.2-alpine + image: redis:7.4.2-alpine options: >- --health-cmd "redis-cli ping" --health-interval 10s @@ -36,7 +36,7 @@ jobs: echo "toss.client-key=${{ secrets.TOSS_CLIENT_KEY }}" >> ./src/main/resources/application.properties echo "toss.secret-key=${{ secrets.TOSS_SECRET_KEY }}" >> ./src/main/resources/application.properties echo "jwt.secret.key=${{ secrets.JWT_SECRET_KEY }}" >> ./src/main/resources/application.properties - echo "spring.redis.host=localhost" >> ./src/main/resources/application.properties + echo "spring.redis.host=redis" >> ./src/main/resources/application.properties echo "spring.redis.port=6379" >> ./src/main/resources/application.properties - name: Build Project From 0e6dc931a51d15ee5ece0ef300840be3de52ac8b Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 12:10:35 +0900 Subject: [PATCH 23/32] =?UTF-8?q?feat=20:=20=ED=8B=B0=EC=BC=93=ED=8C=85=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=8B=9C=EA=B0=84=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/exception/ErrorCode.java | 4 +++- .../reservation/service/WaitingQueueService.java | 11 +++++++++++ .../schedule/repository/ScheduleRepository.java | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java index b56f607..a90293e 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -13,13 +13,15 @@ public enum ErrorCode { NOT_FOUND_USER(404, "유저정보가 존재하지 않습니다."), // schedule + NOT_FOUND_SCHEDULE(404, "공연 회차 정보가 존재하지 않습니다."), MISSING_HEADER(400, "필수 헤더값이 누락되었습니다."), // jwt UNAUTHORIZED(401, "토큰이 유효하지 않습니다."), // queue - QUEUE_INSERT_FAIL(500, "대기열 등록을 실패했습니다."); + QUEUE_INSERT_FAIL(500, "대기열 등록을 실패했습니다."), + NOT_TICKETING_TIME(400, "예매 가능 시간이 아닙니다."); private HttpStatus code; private String message; diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index fa44044..52b7ae9 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -1,11 +1,14 @@ package org.example.siljeun.domain.reservation.service; import jakarta.annotation.PostConstruct; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.exception.ErrorCode; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -22,6 +25,7 @@ public class WaitingQueueService { private static final long ttlMillis = 900000L; // ttl 15분 private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명 private static final String prefixKey = "queue:schedule:"; + private final ScheduleRepository scheduleRepository; // redis 연결 확인 @PostConstruct @@ -33,6 +37,13 @@ public void testRedisConnection() { // 예매 대기 시작 public void addQueue(Long scheduleId, String username) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + + if (LocalDateTime.now().isBefore(schedule.getTicketingStartTime())) { + throw new CustomException(ErrorCode.NOT_TICKETING_TIME); + } + String key = prefixKey + scheduleId; long expiredAt = System.currentTimeMillis() + ttlMillis; ZSetOperations zSet = redisTemplate.opsForZSet(); diff --git a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java index 32a9266..3d52d1e 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java +++ b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java @@ -1,10 +1,13 @@ package org.example.siljeun.domain.schedule.repository; import java.util.List; +import java.util.Optional; import org.example.siljeun.domain.schedule.entity.Schedule; import org.springframework.data.jpa.repository.JpaRepository; public interface ScheduleRepository extends JpaRepository, ScheduleQueryRepository { List findByConcertId(Long concertId); + + Optional findById(Long id); } From 087f58541c01d5822147b2ca8f31e21b9afed348 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 12:32:32 +0900 Subject: [PATCH 24/32] =?UTF-8?q?fix=20:=20seat=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EC=88=98=EC=A0=95=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/dto/response/ReservationInfoResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java index ec5ce02..db6c10b 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/ReservationInfoResponse.java @@ -33,7 +33,7 @@ public static ReservationInfoResponse from(Reservation reservation) { reservation.getSeatScheduleInfo().getGrade(), reservation.getSeatScheduleInfo().getSeat().getSection(), reservation.getSeatScheduleInfo().getSeat().getRow(), - reservation.getSeatScheduleInfo().getSeat().getNumber(), + reservation.getSeatScheduleInfo().getSeat().getColumn(), reservation.getTicketReceipt(), reservation.getPrice(), reservation.getDiscount(), From 68e8de8610263e40717abf7197a7b6b5cad2fe47 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 15:52:18 +0900 Subject: [PATCH 25/32] =?UTF-8?q?refactor=20:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=B4=EB=9E=91=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 67 +++++++------- .../payment/dto/PaymentConfirmRequestDto.java | 15 ++-- .../domain/payment/entity/Payment.java | 38 ++++---- .../payment/service/PaymentService.java | 23 +++-- .../reservation/entity/Reservation.java | 14 --- .../reservation/enums/ReservationStatus.java | 5 -- .../reservation/exception/ErrorCode.java | 3 + .../scheduler/ReservationScheduler.java | 27 ------ .../service/ReservationService.java | 26 +++--- .../service/WaitingQueueService.java | 1 + .../user/repository/UserRepository.java | 1 - .../global/queueing/WebSocketTest.java | 89 +++++++++++++++++++ 12 files changed, 182 insertions(+), 127 deletions(-) delete mode 100644 src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java delete mode 100644 src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java create mode 100644 src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java diff --git a/src/main/java/org/example/siljeun/domain/payment/controller/PaymentController.java b/src/main/java/org/example/siljeun/domain/payment/controller/PaymentController.java index 7f63922..88973ab 100644 --- a/src/main/java/org/example/siljeun/domain/payment/controller/PaymentController.java +++ b/src/main/java/org/example/siljeun/domain/payment/controller/PaymentController.java @@ -15,36 +15,39 @@ @RequestMapping("/payments") public class PaymentController { - private final PaymentService paymentService; - - // 결제 위젯 HTML 페이지 반환 - @GetMapping - public String index() { - return "redirect:/checkout.html"; - } - - // 결제 성공 콜백 → 결제 승인 처리 - @GetMapping("/success") - @ResponseBody - public ResponseEntity sandboxSuccess(@RequestParam String paymentKey, - @RequestParam String orderId, - @RequestParam Long amount) { - System.out.println("결제 성공 콜백 도착"); - System.out.println("paymentKey: " + paymentKey); - System.out.println("orderId: " + orderId); - System.out.println("amount: " + amount); - - PaymentConfirmRequestDto dto = new PaymentConfirmRequestDto(paymentKey, orderId, amount); - paymentService.savePayment(dto); - return ResponseEntity.ok("결제정보 저장 완료"); - } - - - // 결제 실패 콜백 - @GetMapping("/fail") - @ResponseBody - public String sandboxFail(@RequestParam String code, - @RequestParam String message) { - return "결제 실패: " + message + " (" + code + ")"; - } + private final PaymentService paymentService; + + // 결제 위젯 HTML 페이지 반환 + @GetMapping + public String index() { + return "redirect:/checkout.html"; + } + + // 결제 성공 콜백 → 결제 승인 처리 + @GetMapping("/success") + @ResponseBody + public ResponseEntity sandboxSuccess(@RequestParam String paymentKey, + @RequestParam Long userId, + @RequestParam Long seatScheduleInfoId, + @RequestParam Long amount) { + System.out.println("결제 성공 콜백 도착"); + System.out.println("paymentKey: " + paymentKey); + System.out.println("userId: " + userId); + System.out.println("seatScheduleInfoId: " + seatScheduleInfoId); + System.out.println("amount: " + amount); + + PaymentConfirmRequestDto dto = new PaymentConfirmRequestDto(paymentKey, userId, + seatScheduleInfoId, amount); + paymentService.savePayment(dto); + return ResponseEntity.ok("결제정보 저장 완료"); + } + + + // 결제 실패 콜백 + @GetMapping("/fail") + @ResponseBody + public String sandboxFail(@RequestParam String code, + @RequestParam String message) { + return "결제 실패: " + message + " (" + code + ")"; + } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/payment/dto/PaymentConfirmRequestDto.java b/src/main/java/org/example/siljeun/domain/payment/dto/PaymentConfirmRequestDto.java index f58d25a..9fd42c0 100644 --- a/src/main/java/org/example/siljeun/domain/payment/dto/PaymentConfirmRequestDto.java +++ b/src/main/java/org/example/siljeun/domain/payment/dto/PaymentConfirmRequestDto.java @@ -8,12 +8,15 @@ @AllArgsConstructor public class PaymentConfirmRequestDto { - @JsonProperty("paymentKey") - private String paymentKey; + @JsonProperty("paymentKey") + private String paymentKey; - @JsonProperty("orderId") - private String orderId; + @JsonProperty("userId") + private Long userId; - @JsonProperty("amount") - private Long amount; + @JsonProperty("orderId") + private Long seatScheduleInfoId; + + @JsonProperty("amount") + private Long amount; } diff --git a/src/main/java/org/example/siljeun/domain/payment/entity/Payment.java b/src/main/java/org/example/siljeun/domain/payment/entity/Payment.java index f6f601d..513aea3 100644 --- a/src/main/java/org/example/siljeun/domain/payment/entity/Payment.java +++ b/src/main/java/org/example/siljeun/domain/payment/entity/Payment.java @@ -1,10 +1,14 @@ package org.example.siljeun.domain.payment.entity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import org.example.siljeun.global.entity.BaseEntity; @Entity @@ -13,18 +17,18 @@ @Table(name = "payments") public class Payment extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - private String orderId; - private String paymentKey; - private Long amount; + private Long seatScheduleInfoId; + private String paymentKey; + private Long amount; - @Builder - public Payment(String orderId, String paymentKey, Long amount) { - this.orderId = orderId; - this.paymentKey = paymentKey; - this.amount = amount; - } + @Builder + public Payment(Long seatScheduleInfoId, String paymentKey, Long amount) { + this.seatScheduleInfoId = seatScheduleInfoId; + this.paymentKey = paymentKey; + this.amount = amount; + } } diff --git a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java index 8dba1f0..3f778fd 100644 --- a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java +++ b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java @@ -4,21 +4,26 @@ import org.example.siljeun.domain.payment.dto.PaymentConfirmRequestDto; import org.example.siljeun.domain.payment.entity.Payment; import org.example.siljeun.domain.payment.repository.PaymentRepository; +import org.example.siljeun.domain.reservation.service.ReservationService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; + private final PaymentRepository paymentRepository; + private final ReservationService reservationService; - public void savePayment(PaymentConfirmRequestDto dto) { - Payment payment = Payment.builder() - .paymentKey(dto.getPaymentKey()) - .orderId(dto.getOrderId()) - .amount(dto.getAmount()) - .build(); + @Transactional + public void savePayment(PaymentConfirmRequestDto dto) { + Payment payment = Payment.builder() + .paymentKey(dto.getPaymentKey()) + .seatScheduleInfoId(dto.getSeatScheduleInfoId()) + .amount(dto.getAmount()) + .build(); - paymentRepository.save(payment); - } + paymentRepository.save(payment); + reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); + } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index f0f5086..b1ed8b5 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -1,8 +1,5 @@ package org.example.siljeun.domain.reservation.entity; -import static org.example.siljeun.domain.reservation.enums.ReservationStatus.COMPLETE; -import static org.example.siljeun.domain.reservation.enums.ReservationStatus.PENDING; - import io.micrometer.common.util.StringUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -21,7 +18,6 @@ import lombok.NoArgsConstructor; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; import org.example.siljeun.domain.reservation.enums.Discount; -import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.example.siljeun.domain.reservation.enums.TicketReceipt; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; @@ -60,10 +56,6 @@ public class Reservation { @Enumerated(EnumType.STRING) private Discount discount; - @Column(nullable = false) - @Enumerated(EnumType.STRING) - private ReservationStatus status; - @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -74,12 +66,6 @@ public Reservation(User user, SeatScheduleInfo seatScheduleInfo) { this.price = seatScheduleInfo.getPrice(); this.ticketReceipt = TicketReceipt.PICKUP; this.discount = Discount.GENERAL; - this.status = PENDING; - } - - public void updateReservationStatus(Reservation reservation) { - this.status = (reservation.status == PENDING) ? - COMPLETE : PENDING; } public void updateTicketPrice(UpdatePriceRequest dto) { diff --git a/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java b/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java deleted file mode 100644 index 72dc51e..0000000 --- a/src/main/java/org/example/siljeun/domain/reservation/enums/ReservationStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.reservation.enums; - -public enum ReservationStatus { - PENDING, COMPLETE -} diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java index a90293e..6c0b852 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -16,6 +16,9 @@ public enum ErrorCode { NOT_FOUND_SCHEDULE(404, "공연 회차 정보가 존재하지 않습니다."), MISSING_HEADER(400, "필수 헤더값이 누락되었습니다."), + // seatScheduleInfo + NOT_FOUNT_SEAT_SCHEDULE_INFO(404, "해당 공연에 대한 좌석 정보가 존재하지 않습니다."), + // jwt UNAUTHORIZED(401, "토큰이 유효하지 않습니다."), diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java deleted file mode 100644 index 32a7152..0000000 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.example.siljeun.domain.reservation.scheduler; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.reservation.enums.ReservationStatus; -import org.example.siljeun.domain.reservation.repository.ReservationRepository; -import org.example.siljeun.domain.reservation.service.ReservationService; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ReservationScheduler { - - private final ReservationRepository reservationRepository; - private final ReservationService reservationService; - - @Scheduled(fixedRate = 60000) // 1분마다 실행 - public void returnSeat() { - LocalDateTime now = LocalDateTime.now(); - - reservationRepository.findByStatus(ReservationStatus.PENDING).stream() - .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreatedAt(), now) >= 7) - .forEach(reservationService::deleteByScheduling); - } -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 8233842..3a03fa6 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -1,12 +1,13 @@ package org.example.siljeun.domain.reservation.service; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.dto.request.UpdatePriceRequest; +import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.entity.Reservation; -import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.reservation.repository.ReservationRepository; +import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; @@ -20,21 +21,20 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; - // 좌석 도메인에서 호출할 메서드 - 예매 정보 저장 @Transactional - public void save(User user, SeatScheduleInfo seatScheduleInfo) { + public void save(Long userId, Long seatScheduleInfoId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername()); } - // 결제 도메인에서 호출할 메서드 - 결제완료 or 결제취소 처리 - @Transactional - public void updateReservationStatus(Reservation reservation) { - reservation.updateReservationStatus(reservation); - } - @Transactional public void updatePrice(String username, Long reservationId, UpdatePriceRequest requestDto) { User user = userRepository.findByUsername(username) @@ -64,12 +64,6 @@ public void delete(String username, Long reservationId) { // Todo : 좌석 상태 변경 } - @Transactional - public void deleteByScheduling(Reservation reservation) { - reservationRepository.delete(reservation); - // Todo : 좌석 상태 변경 - } - public ReservationInfoResponse findById(String username, Long reservationId) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index 52b7ae9..e73bab1 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -84,6 +84,7 @@ public void deleteAtQueue(Long scheduleId, String username) { } // sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인 + // Todo : 좌석 선택 메서드 안에서 호출(정상 경로로 접근했는지 검증 필요) public boolean checkQueue(Long scheduleId, String username) { boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null; return exists; diff --git a/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java b/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java index 6a5ec64..fe3427e 100644 --- a/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java +++ b/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java @@ -9,5 +9,4 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); - } \ No newline at end of file diff --git a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java new file mode 100644 index 0000000..e85fc3c --- /dev/null +++ b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java @@ -0,0 +1,89 @@ +package org.example.siljeun.global.queueing; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; +import org.example.siljeun.domain.concert.entity.Concert; +import org.example.siljeun.domain.concert.entity.ConcertCategory; +import org.example.siljeun.domain.concert.repository.ConcertRepository; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.example.siljeun.domain.venue.entity.Venue; +import org.example.siljeun.domain.venue.repository.VenueRepository; +import org.example.siljeun.global.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class WebSocketTest { + + @LocalServerPort + private int port; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + private JwtUtil jwtUtil; + + private String validToken; + + private Schedule savedSchedule; + + @BeforeEach + void setup() { + Concert savedConcert; + Venue savedVenue; + + savedVenue = venueRepository.save(new Venue("name", "location", 1000)); + savedConcert = concertRepository.save( + new Concert("title", "description", ConcertCategory.CONCERT, savedVenue, 1)); + savedSchedule = scheduleRepository.save( + new Schedule(savedConcert, LocalDateTime.now(), LocalDateTime.now())); + validToken = jwtUtil.createToken("testUser"); + } + + @Test + void socket_connection_test() throws Exception { + + WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + URI uri = new URI( + "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId() + "&token=" + + validToken); + + WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); + webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); + + StompHeaders stompHeaders = new StompHeaders(); + + StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, + new StompSessionHandlerAdapter() { + } + ).get(5, TimeUnit.SECONDS); + + assertTrue(session.isConnected()); + } +} From a9e8846d91c0ebd41511f401cd8506862e7696b1 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 16:00:32 +0900 Subject: [PATCH 26/32] =?UTF-8?q?chore=20:=20=EC=82=AC=EC=9A=A9=EC=95=88?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ReservationRepository.java | 3 --- .../scheduler/ReservationScheduler.java | 27 ------------------- 2 files changed, 30 deletions(-) delete mode 100644 src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java index 6e43ed0..16071ef 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/org/example/siljeun/domain/reservation/repository/ReservationRepository.java @@ -1,11 +1,8 @@ package org.example.siljeun.domain.reservation.repository; -import java.util.List; import org.example.siljeun.domain.reservation.entity.Reservation; -import org.example.siljeun.domain.reservation.enums.ReservationStatus; import org.springframework.data.jpa.repository.JpaRepository; public interface ReservationRepository extends JpaRepository { - List findByStatus(ReservationStatus status); } diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java deleted file mode 100644 index 32a7152..0000000 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/ReservationScheduler.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.example.siljeun.domain.reservation.scheduler; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.reservation.enums.ReservationStatus; -import org.example.siljeun.domain.reservation.repository.ReservationRepository; -import org.example.siljeun.domain.reservation.service.ReservationService; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ReservationScheduler { - - private final ReservationRepository reservationRepository; - private final ReservationService reservationService; - - @Scheduled(fixedRate = 60000) // 1분마다 실행 - public void returnSeat() { - LocalDateTime now = LocalDateTime.now(); - - reservationRepository.findByStatus(ReservationStatus.PENDING).stream() - .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreatedAt(), now) >= 7) - .forEach(reservationService::deleteByScheduling); - } -} \ No newline at end of file From d56f615890625a0937f921802c9670f1e1ff4a76 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 17:00:08 +0900 Subject: [PATCH 27/32] =?UTF-8?q?refactor=20:=20=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=8B=9C=20=ED=97=A4=EB=8D=94=EC=97=90?= =?UTF-8?q?=EC=84=9C=20token=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/enums/.DS_Store | Bin 0 -> 6148 bytes .../example/siljeun/global/queueing/.DS_Store | Bin 0 -> 6148 bytes .../queueing/JwtHandShakeInterceptor.java | 20 +++++++++--------- src/test/java/org/example/siljeun/.DS_Store | Bin 0 -> 6148 bytes 4 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/enums/.DS_Store create mode 100644 src/main/java/org/example/siljeun/global/queueing/.DS_Store create mode 100644 src/test/java/org/example/siljeun/.DS_Store diff --git a/src/main/java/org/example/siljeun/domain/reservation/enums/.DS_Store b/src/main/java/org/example/siljeun/domain/reservation/enums/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 attributes) throws Exception { + HttpHeaders headers = request.getHeaders(); URI uri = request.getURI(); MultiValueMap params = UriComponentsBuilder.fromUri(uri).build() .getQueryParams(); + String token = headers.getFirst("Authorization"); String scheduleId = params.getFirst("scheduleId"); - if (StringUtils.isBlank(scheduleId) || !scheduleRepository.existsById( - Long.valueOf(scheduleId))) { - throw new CustomException(ErrorCode.MISSING_HEADER); + if (!jwtUtil.validateToken(token)) { + return false; } - attributes.put("scheduleId", scheduleId); - - String jwt = params.getFirst("token"); - - if (!jwtUtil.validateToken(jwt)) { - return false; + if (StringUtils.isBlank(scheduleId) || !scheduleRepository.existsById( + Long.valueOf(scheduleId))) { + throw new CustomException(ErrorCode.MISSING_HEADER); // or return false } - String username = jwtUtil.getUsername(jwt); + String username = jwtUtil.getUsername(token); attributes.put("username", username); + attributes.put("scheduleId", scheduleId); return true; } diff --git a/src/test/java/org/example/siljeun/.DS_Store b/src/test/java/org/example/siljeun/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Fri, 23 May 2025 03:33:22 +0900 Subject: [PATCH 28/32] =?UTF-8?q?feat=20:=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?TTL=EC=9D=84=20=EC=A2=8C=EC=84=9D=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=91=EA=B7=BC=20=ED=9B=84=EB=B6=80?= =?UTF-8?q?=ED=84=B0=20=EC=A0=81=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 8 +- .../controller/WaitingQueueController.java | 2 +- .../dto/response/MyQueueInfoResponse.java | 2 +- .../scheduler/CheckExpiredScheduler.java | 99 +++++++++++++++++ .../service/ReservationService.java | 3 +- .../service/WaitingQueueService.java | 105 +++++++++++++----- .../repository/ScheduleRepository.java | 9 +- .../StompDisconnectEventListener.java | 3 +- 8 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index d934107..08a79e2 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -6,7 +6,7 @@ import org.example.siljeun.domain.reservation.dto.response.ReservationInfoResponse; import org.example.siljeun.domain.reservation.service.ReservationService; import org.example.siljeun.global.dto.ResponseDto; -import org.example.siljeun.global.security.CustomUserDetails; +import org.example.siljeun.global.security.PrincipalDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -26,7 +26,7 @@ public class ReservationController { @PatchMapping("/{reservationId}/discount") public ResponseEntity> updatePrice( - @AuthenticationPrincipal CustomUserDetails userDetails, + @AuthenticationPrincipal PrincipalDetails userDetails, @PathVariable Long reservationId, @RequestBody @Valid UpdatePriceRequest requestDto) { String username = userDetails.getUsername(); @@ -36,7 +36,7 @@ public ResponseEntity> updatePrice( @DeleteMapping("/{reservationId}") public ResponseEntity> delete( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId) { + @AuthenticationPrincipal PrincipalDetails userDetails, @PathVariable Long reservationId) { String username = userDetails.getUsername(); reservationService.delete(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 취소 완료", null)); @@ -44,7 +44,7 @@ public ResponseEntity> delete( @GetMapping("/{reservationId}") public ResponseEntity> findById( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long reservationId) { + @AuthenticationPrincipal PrincipalDetails userDetails, @PathVariable Long reservationId) { String username = userDetails.getUsername(); ReservationInfoResponse dto = reservationService.findById(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 조회 성공", dto)); diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index fb907ee..03e4061 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -17,7 +17,7 @@ public class WaitingQueueController { public void addQueue(@Valid AddQueueRequest request) { Long scheduleId = request.scheduleId(); String username = request.username(); - waitingQueueService.addQueue(scheduleId, username); + waitingQueueService.addWaitingQueue(scheduleId, username); System.out.println("연결 성공"); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java index cacf256..19daeaa 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java @@ -4,7 +4,7 @@ public record MyQueueInfoResponse( Long scheduleId, String username, Long rank, - Long acceptedRank + boolean isPassable ) { } diff --git a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java new file mode 100644 index 0000000..f520b0e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java @@ -0,0 +1,99 @@ +package org.example.siljeun.domain.reservation.scheduler; + +import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForSelecingQueue; +import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForWaitingQueue; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.schedule.entity.Schedule; +import org.example.siljeun.domain.schedule.repository.ScheduleRepository; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CheckExpiredScheduler { + + private final StringRedisTemplate redisTemplate; + private final ScheduleRepository scheduleRepository; + + private final Set keys = new HashSet<>(); + + // 1시간마다 티켓팅 기간인 schedule을 keys에 저장 + @Scheduled(cron = "0 0 * * * *") + public void checkOpenedSchedule() { + + keys.clear(); + + List openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore( + LocalDateTime.now(), + LocalDateTime.now()).stream() + .map(Schedule::getId) + .toList(); + + try (Cursor cursor = redisTemplate.getConnectionFactory().getConnection() + .scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*") + .build())) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + String[] parts = key.split(":"); + Long scheduleId = Long.valueOf(parts[2]); + + if (openedSchedules.contains(scheduleId)) { + keys.add(key); + } + } + } + } + + // 1분마다 keys에 저장된 각 schedule의 대기열에서 TTL 만료인 유저 삭제 + @Scheduled(cron = "0 * * * * *") + public void checkExpiredUser() { + + for (String key : keys) { + redisTemplate.opsForZSet() + .removeRangeByScore(key, 0, System.currentTimeMillis()); + } + } + + // 1일마다 예매 종료된 공연은 sorted set에서 삭제 + @Scheduled(cron = "0 0 0 * * *") + public void deleteExpiredKey() { + + Set scheduleIdForDelete = new HashSet<>(); + + // sorted set에 저장된 scheduleId 추출 + try (Cursor cursor = redisTemplate.getConnectionFactory().getConnection() + .scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*") + .build())) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + String[] parts = key.split(":"); + Long scheduleId = Long.valueOf(parts[2]); + scheduleIdForDelete.add(scheduleId); + } + } + + // schedule.startTime < 현재 시각인 schedule 추출 + List schedules = scheduleRepository.findByIdInAndStartTimeBefore( + new ArrayList<>(scheduleIdForDelete), + LocalDateTime.now() + ); + + // sorted set 에서 제거 + schedules.stream() + .map(schedule -> prefixKeyForWaitingQueue + schedule.getId()) + .forEach(redisTemplate::delete); + schedules.stream() + .map(schedule -> prefixKeyForSelecingQueue + schedule.getId()) + .forEach(redisTemplate::delete); + } +} diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 3a03fa6..2a78b50 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -32,7 +32,8 @@ public void save(Long userId, Long seatScheduleInfoId) { Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); - waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername()); + waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(), + user.getUsername()); } @Transactional diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java index e73bab1..41be5ad 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java @@ -2,6 +2,7 @@ import jakarta.annotation.PostConstruct; import java.time.LocalDateTime; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse; @@ -13,19 +14,22 @@ import org.springframework.data.redis.core.ZSetOperations; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor +@Transactional public class WaitingQueueService { private final StringRedisTemplate redisTemplate; private final SimpMessagingTemplate messagingTemplate; + private final ScheduleRepository scheduleRepository; private static final long ttlMillis = 900000L; // ttl 15분 private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명 - private static final String prefixKey = "queue:schedule:"; - private final ScheduleRepository scheduleRepository; + public static final String prefixKeyForWaitingQueue = "waiting:schedule:"; + public static final String prefixKeyForSelecingQueue = "selecting:schedule:"; // redis 연결 확인 @PostConstruct @@ -35,7 +39,8 @@ public void testRedisConnection() { } // 예매 대기 시작 - public void addQueue(Long scheduleId, String username) { + public void addWaitingQueue(Long scheduleId, String username) { + ZSetOperations zSet = redisTemplate.opsForZSet(); Schedule schedule = scheduleRepository.findById(scheduleId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); @@ -44,49 +49,95 @@ public void addQueue(Long scheduleId, String username) { throw new CustomException(ErrorCode.NOT_TICKETING_TIME); } - String key = prefixKey + scheduleId; - long expiredAt = System.currentTimeMillis() + ttlMillis; - ZSetOperations zSet = redisTemplate.opsForZSet(); + String key = prefixKeyForWaitingQueue + scheduleId; + long createdAt = System.currentTimeMillis(); if (zSet.score(key, username) == null) { + zSet.add(key, username, createdAt); + } + + sendWaitingNumber(key, username, scheduleId); + } + + // 좌석 선택 중인 유저 큐에 insert (TTL 관리용 큐) + public void addSelectingQueue(Long scheduleId, String username) { + ZSetOperations zSet = redisTemplate.opsForZSet(); + + String key = prefixKeyForSelecingQueue + scheduleId; + Long expiredAt = System.currentTimeMillis() + ttlMillis; + + if (zSet.score(prefixKeyForSelecingQueue + scheduleId, username) == null) { zSet.add(key, username, expiredAt); } + } + + // 대기 끝 or 소켓 연결 해제되면 대기열에서 삭제 + public void deleteWaitingUser(Long scheduleId, String username) { + String key = prefixKeyForWaitingQueue + scheduleId; + redisTemplate.opsForZSet().remove(key, username); + } + + // 좌석 선택 완료 or 소켓 연결 해제 or TTL 만료되면 큐에서 삭제 + public void deleteSelectingUser(Long scheduleId, String username) { + String key = prefixKeyForSelecingQueue + scheduleId; + redisTemplate.opsForZSet().remove(key, username); + sendAllWaitingNumber(scheduleId); + } + + // 정상적인 경로로 좌석 선택 api 호출했는지 검증 + public boolean hasPassedWaitingQueue(Long scheduleId, String username) { + return + redisTemplate.opsForZSet().score(prefixKeyForSelecingQueue + scheduleId, username) != null; + } + + // 대기중인 특정 사용자에게 랭킹 및 대기번호 전송 + public void sendWaitingNumber(String key, String username, Long scheduleId) { + ZSetOperations zSet = redisTemplate.opsForZSet(); Long rank = zSet.rank(key, username); + if (rank == null) { throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL); } + rank = rank + 1; - String destination = "/topic/queue/" + scheduleId + "/" + username; - MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, - acceptedRank); - messagingTemplate.convertAndSend(destination, response); - } + // 내 순위와 현재 좌석 선택 중인 사용자 수의 합이 수용 인원보다 적으면 대기 X + Long selectingQueueSize = zSet.size(prefixKeyForSelecingQueue + scheduleId); + selectingQueueSize = (selectingQueueSize == null) ? 0 : selectingQueueSize; - // 기존 유저가 좌석 선택 완료 or 소켓 연결 종료하면 대기열에서 삭제 - public void deleteAtQueue(Long scheduleId, String username) { - redisTemplate.opsForZSet().remove(prefixKey + scheduleId, username); - log.info("Disconnected and removed Schedule: {}, User: {}", scheduleId, username); + if (rank + selectingQueueSize <= acceptedRank) { + String destination = "/topic/queue/" + scheduleId + "/" + username; + MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, + true); + messagingTemplate.convertAndSend(destination, response); - // TTL 만료된 데이터 삭제 - redisTemplate.opsForZSet() - .removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis()); + addSelectingQueue(scheduleId, username); + deleteWaitingUser(scheduleId, username); - // rank() 재실행해서 변경된 대기번호 클라이언트에 전송 - Long rank = redisTemplate.opsForZSet().rank(prefixKey + scheduleId, username); - rank = (rank != null) ? rank + 1 : -1; + return; + } String destination = "/topic/queue/" + scheduleId + "/" + username; MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, - acceptedRank); + false); messagingTemplate.convertAndSend(destination, response); } - // sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인 - // Todo : 좌석 선택 메서드 안에서 호출(정상 경로로 접근했는지 검증 필요) - public boolean checkQueue(Long scheduleId, String username) { - boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null; - return exists; + // 대기중인 모든 사용자에게 랭킹 및 대기번호 전송 + public void sendAllWaitingNumber(Long scheduleId) { + String key = prefixKeyForWaitingQueue + scheduleId; + + // for문이나 stream으로 scheduleId에 해당하는 value값 리스트 추출 + Set usernames = redisTemplate.opsForZSet().range(key, 0, -1); + + if (usernames == null || usernames.isEmpty()) { + return; + } + + // 해당 유저들한테 메세지 전송 + for (String username : usernames) { + sendWaitingNumber(key, username, scheduleId); + } } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java index f95d9fe..fa3643b 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java +++ b/src/main/java/org/example/siljeun/domain/schedule/repository/ScheduleRepository.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.schedule.repository; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.example.siljeun.domain.schedule.entity.Schedule; @@ -10,7 +11,13 @@ public interface ScheduleRepository extends JpaRepository, Sched List findByConcertId(Long concertId); - List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, LocalDateTime ticketingStartTimeBefore); + List findAllByTicketingStartTimeBetween(LocalDateTime ticketingStartTimeAfter, + LocalDateTime ticketingStartTimeBefore); Optional findById(Long id); + + List findAllByStartTimeAfterAndTicketingStartTimeBefore(LocalDateTime now, + LocalDateTime now1); + + List findByIdInAndStartTimeBefore(ArrayList longs, LocalDateTime now); } diff --git a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java index 4a37340..1ae8dfe 100644 --- a/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java +++ b/src/main/java/org/example/siljeun/global/queueing/StompDisconnectEventListener.java @@ -21,7 +21,8 @@ public void handleDisconnect(SessionDisconnectEvent event) { Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId")); if (username != null && scheduleId != null) { - waitingQueueService.deleteAtQueue(scheduleId, username); + waitingQueueService.deleteWaitingUser(scheduleId, username); + waitingQueueService.deleteSelectingUser(scheduleId, username); } } } From 07c3a31c6d24f822e53d1ff9d6995982c9a11812 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Fri, 23 May 2025 03:38:10 +0900 Subject: [PATCH 29/32] =?UTF-8?q?feat=20:=20=EC=98=88=EB=A7=A4=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=EC=8B=9C=20=EC=A2=8C=EC=84=9D=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../siljeun/domain/reservation/service/ReservationService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 2a78b50..5b1767d 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -9,6 +9,7 @@ import org.example.siljeun.domain.reservation.repository.ReservationRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seat.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -62,7 +63,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - // Todo : 좌석 상태 변경 + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { From 45ace482fde21f95cfc688354b44635ab34b1c71 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Fri, 23 May 2025 10:09:02 +0900 Subject: [PATCH 30/32] =?UTF-8?q?feat=20:=20SeatScheduleInfo=20Service?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=EA=B8=B0=EC=97=B4=20passed=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=A2=8C=EC=84=9D?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20queue?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/AddQueueRequest.java | 3 +- .../exception/CustomException.java | 5 +- .../ReservationExceptionHandler.java | 2 +- .../SeatScheduleInfoController.java | 36 ++-- .../service/SeatScheduleInfoService.java | 129 ++++++------ .../service/SeatScheduleInfoServiceTest.java | 184 +++++++++--------- 6 files changed, 188 insertions(+), 171 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java index c3472a1..e1c862b 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java +++ b/src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java @@ -1,9 +1,10 @@ package org.example.siljeun.domain.reservation.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record AddQueueRequest( - @NotBlank + @NotNull Long scheduleId, @NotBlank String username diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java index 6549d4c..a650626 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/CustomException.java @@ -1,15 +1,14 @@ package org.example.siljeun.domain.reservation.exception; import lombok.Getter; -import org.springframework.http.HttpStatus; public class CustomException extends RuntimeException { @Getter - private HttpStatus errorCode; + private ErrorCode errorCode; public CustomException(ErrorCode errorCode) { super(errorCode.getMessage()); - this.errorCode = errorCode.getCode(); + this.errorCode = errorCode; } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java index 2790264..154a14d 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java @@ -15,7 +15,7 @@ public class ReservationExceptionHandler { @ExceptionHandler(CustomException.class) public ResponseEntity> reservationExceptionHandler( CustomException e) { - return ResponseEntity.status(e.getErrorCode()) + return ResponseEntity.status(e.getErrorCode().getCode()) .body(ResponseDto.fail(e.getMessage())); } diff --git a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java index a450ff9..c862976 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java @@ -2,38 +2,36 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.schedule.service.SeatScheduleInfoService; -import org.example.siljeun.domain.seat.dto.response.SeatScheduleInfoResponse; -import org.example.siljeun.global.security.CustomUserDetails; +import org.example.siljeun.global.security.PrincipalDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; 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.RequestMapping; -import java.util.List; import java.util.Map; @Controller @RequiredArgsConstructor public class SeatScheduleInfoController { - private final SeatScheduleInfoService seatScheduleInfoService; + private final SeatScheduleInfoService seatScheduleInfoService; - @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") - public ResponseEntity selectSeat( - @PathVariable Long seatScheduleInfoId, - @AuthenticationPrincipal CustomUserDetails userDetails - ){ - seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId); - return ResponseEntity.ok("좌석이 선택되었습니다."); - } + @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") + public ResponseEntity selectSeat( + @PathVariable Long seatScheduleInfoId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + seatScheduleInfoService.selectSeat(userDetails.getUserId(), userDetails.getUsername(), + seatScheduleInfoId); + return ResponseEntity.ok("좌석이 선택되었습니다."); + } - @GetMapping("/schedule/{scheduleId}/seat-schedule-info") - public ResponseEntity> getSeatScheduleInfos( - @PathVariable Long scheduleId - ){ - return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); - } + @GetMapping("/schedule/{scheduleId}/seat-schedule-info") + public ResponseEntity> getSeatScheduleInfos( + @PathVariable Long scheduleId + ) { + return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); + } } diff --git a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java index 0e5bfdc..9f83f05 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoService.java @@ -1,7 +1,12 @@ package org.example.siljeun.domain.schedule.service; import jakarta.persistence.EntityNotFoundException; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.reservation.service.WaitingQueueService; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository; @@ -12,80 +17,90 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - @Slf4j @Service public class SeatScheduleInfoService { - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final ScheduleRepository scheduleRepository; - private final RedisTemplate redisSeatUserTemplate; - private final RedisTemplate redisStatusTemplate; - - public SeatScheduleInfoService( - SeatScheduleInfoRepository seatScheduleInfoRepository, - ScheduleRepository scheduleRepository, - @Qualifier("redisLongTemplate") RedisTemplate redisSeatUserTemplate, - @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate - ){ - this.seatScheduleInfoRepository = seatScheduleInfoRepository; - this.scheduleRepository = scheduleRepository; - this.redisSeatUserTemplate = redisSeatUserTemplate; - this.redisStatusTemplate = redisStatusTemplate; - } - @DistributedLock(key = "'seat:' + #seatScheduleInfoId") - public void selectSeat(Long userId, Long seatScheduleInfoId) { - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisSeatUserTemplate; + private final RedisTemplate redisStatusTemplate; + private final WaitingQueueService waitingQueueService; + + public SeatScheduleInfoService( + SeatScheduleInfoRepository seatScheduleInfoRepository, + ScheduleRepository scheduleRepository, + @Qualifier("redisLongTemplate") RedisTemplate redisSeatUserTemplate, + @Qualifier("redisStringTemplate") RedisTemplate redisStatusTemplate, + WaitingQueueService waitingQueueService) { + this.seatScheduleInfoRepository = seatScheduleInfoRepository; + this.scheduleRepository = scheduleRepository; + this.redisSeatUserTemplate = redisSeatUserTemplate; + this.redisStatusTemplate = redisStatusTemplate; + this.waitingQueueService = waitingQueueService; + } + + @Transactional + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") + public void selectSeat(Long userId, String username, Long seatScheduleInfoId) { + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + boolean hasPassedQueue = waitingQueueService.hasPassedWaitingQueue( + seatScheduleInfo.getSchedule().getId(), username); + if (!hasPassedQueue) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "정상적인 접근이 아닙니다."); + } - if (!seatScheduleInfo.isAvailable()) { - //log.info("이미 선점된 좌석입니다."); - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); - } + if (!seatScheduleInfo.isAvailable()) { + //log.info("이미 선점된 좌석입니다."); + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); + } - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); - String redisKey = "seat:" + seatScheduleInfoId; - redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); + String redisKey = "seat:" + seatScheduleInfoId; + redisSeatUserTemplate.opsForValue().set(redisKey, userId, Duration.ofMinutes(5)); - String redisStatusKey = "seatStatus:" + seatScheduleInfoId; - redisStatusTemplate.opsForValue().set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); - } + String redisStatusKey = "seatStatus:" + seatScheduleInfoId; + redisStatusTemplate.opsForValue() + .set(redisStatusKey, seatScheduleInfo.getStatus().name(), Duration.ofMinutes(5)); - public Map getSeatStatusMap(Long scheduleId) { + waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(), username); + } - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + public Map getSeatStatusMap(Long scheduleId) { - List seatScheduleInfos = - seatScheduleInfoRepository.findAllBySchedule(schedule); + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); - Map result = new HashMap<>(); + List seatScheduleInfos = + seatScheduleInfoRepository.findAllBySchedule(schedule); - for (SeatScheduleInfo info : seatScheduleInfos) { - String redisKey = "seatStatus:" + info.getId(); - String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); + Map result = new HashMap<>(); - String status; - if (redisStatus != null) { - status = redisStatus; - } else if (info.getStatus() == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우 - status = SeatStatus.AVAILABLE.name(); - } else { - status = info.getStatus().name(); - } + for (SeatScheduleInfo info : seatScheduleInfos) { + String redisKey = "seatStatus:" + info.getId(); + String redisStatus = redisStatusTemplate.opsForValue().get(redisKey); - result.put("seatScheduleInfo-" + info.getId().toString(), status); - } + String status; + if (redisStatus != null) { + status = redisStatus; + } else if (info.getStatus() + == SeatStatus.SELECTED) { //TTL에 의해서 Redis에서는 만료되었으나 DB에 Selected로 저장된 경우 + status = SeatStatus.AVAILABLE.name(); + } else { + status = info.getStatus().name(); + } - return result; + result.put("seatScheduleInfo-" + info.getId().toString(), status); } + + return result; + } } diff --git a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java index e225c9d..8454bad 100644 --- a/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java +++ b/src/test/java/org/example/siljeun/domain/schedule/service/SeatScheduleInfoServiceTest.java @@ -1,5 +1,16 @@ package org.example.siljeun.domain.schedule.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; import org.example.siljeun.domain.concert.entity.Concert; import org.example.siljeun.domain.concert.entity.ConcertCategory; import org.example.siljeun.domain.concert.repository.ConcertRepository; @@ -21,101 +32,94 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.stream.IntStream; - -import static org.junit.jupiter.api.Assertions.*; - @SpringBootTest @AutoConfigureMockMvc @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SeatScheduleInfoServiceTest { - @Autowired - private SeatScheduleInfoService seatScheduleInfoService; - - @Autowired - private SeatScheduleInfoRepository seatScheduleInfoRepository; - - @Autowired - private VenueRepository venueRepository; - - @Autowired - private VenueSeatRepository venueSeatRepository; - - @Autowired - private ScheduleRepository scheduleRepository; - - @Autowired - private ConcertRepository concertRepository; - - @Autowired - @Qualifier("redisLongTemplate") - private RedisTemplate redisTemplate; - - private Seat seat; - private Schedule schedule; - - @BeforeEach - void setUp() { - redisTemplate.getConnectionFactory().getConnection().flushAll(); - Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); - seat = venueSeatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); - Concert concert = concertRepository.save(new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); - schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), LocalDateTime.of(2025, 5, 6, 10, 0))); - } - - @Test - @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") - void sameSeatConcurrentAccessTest() throws InterruptedException { - // given - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save(new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), seat.getDefaultPrice())); - Long seatScheduleInfoId = seatScheduleInfo.getId(); - int totalThreads = 1000; - ExecutorService executor = Executors.newFixedThreadPool(100); - CountDownLatch latch = new CountDownLatch(totalThreads); - List resultMessages = Collections.synchronizedList(new ArrayList<>()); - - // when - IntStream.range(0, totalThreads).forEach(i -> { - executor.submit(() -> { - try { - seatScheduleInfoService.selectSeat((long) i + 1, seatScheduleInfoId); - resultMessages.add("SUCCESS"); - } catch (ResponseStatusException e) { - resultMessages.add(e.getReason()); - } finally { - latch.countDown(); - } - }); - }); - - latch.await(); - - // then - long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); - long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); - - System.out.println("\n성공 요청 수: " + successCount); - System.out.println("실패 요청 수: " + conflictCount); - - assertEquals(1, successCount); - assertEquals(totalThreads - 1, conflictCount); - - SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId).orElseThrow(); - assertEquals(SeatStatus.SELECTED, updated.getStatus()); - - Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); - assertNotNull(storedUserId); - System.out.println("Redis에 저장된 유저 ID: " + storedUserId); - } + @Autowired + private SeatScheduleInfoService seatScheduleInfoService; + + @Autowired + private SeatScheduleInfoRepository seatScheduleInfoRepository; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private VenueSeatRepository venueSeatRepository; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + @Qualifier("redisLongTemplate") + private RedisTemplate redisTemplate; + + private Seat seat; + private Schedule schedule; + + @BeforeEach + void setUp() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + Venue venue = venueRepository.save(new Venue("샤롯데씨어터", "잠실 어딘가", 1)); + seat = venueSeatRepository.save(new Seat(venue, "A", "1", "1", "VIP", 180000)); + Concert concert = concertRepository.save( + new Concert("위키드", "엘파바와 글린다", ConcertCategory.MUSICAL, venue, 1000)); + schedule = scheduleRepository.save(new Schedule(concert, LocalDateTime.of(2025, 6, 6, 14, 30), + LocalDateTime.of(2025, 5, 6, 10, 0))); + } + + @Test + @DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지") + void sameSeatConcurrentAccessTest() throws InterruptedException { + // given + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save( + new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), + seat.getDefaultPrice())); + Long seatScheduleInfoId = seatScheduleInfo.getId(); + int totalThreads = 1000; + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(totalThreads); + List resultMessages = Collections.synchronizedList(new ArrayList<>()); + + // when + IntStream.range(0, totalThreads).forEach(i -> { + executor.submit(() -> { + try { + seatScheduleInfoService.selectSeat((long) i + 1, "testUser", seatScheduleInfoId); + resultMessages.add("SUCCESS"); + } catch (ResponseStatusException e) { + resultMessages.add(e.getReason()); + } finally { + latch.countDown(); + } + }); + }); + + latch.await(); + + // then + long successCount = resultMessages.stream().filter("SUCCESS"::equals).count(); + long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count(); + + System.out.println("\n성공 요청 수: " + successCount); + System.out.println("실패 요청 수: " + conflictCount); + + assertEquals(1, successCount); + assertEquals(totalThreads - 1, conflictCount); + + SeatScheduleInfo updated = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(); + assertEquals(SeatStatus.SELECTED, updated.getStatus()); + + Long storedUserId = redisTemplate.opsForValue().get("seat:" + seatScheduleInfoId); + assertNotNull(storedUserId); + System.out.println("Redis에 저장된 유저 ID: " + storedUserId); + } } \ No newline at end of file From 075c532bb4faf1cbd71c82bb2044dba903bf88ea Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Fri, 23 May 2025 12:58:47 +0900 Subject: [PATCH 31/32] =?UTF-8?q?refactor=20:=20=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=A0=84=EC=86=A1=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20body=20=EC=95=88=EB=84=A3=EC=96=B4=EB=8F=84=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WaitingQueueController.java | 15 ++++++++++----- .../domain/reservation/entity/Reservation.java | 2 +- .../controller/SeatScheduleInfoController.java | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java index 03e4061..5803700 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java @@ -1,10 +1,10 @@ package org.example.siljeun.domain.reservation.controller; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.reservation.dto.request.AddQueueRequest; import org.example.siljeun.domain.reservation.service.WaitingQueueService; +import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.web.bind.annotation.RestController; @RestController @@ -14,9 +14,14 @@ public class WaitingQueueController { private final WaitingQueueService waitingQueueService; @MessageMapping("/addQueue") - public void addQueue(@Valid AddQueueRequest request) { - Long scheduleId = request.scheduleId(); - String username = request.username(); + public void addQueue(Message message) { + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(message); + + String username = (String) accessor.getSessionAttributes().get("username"); + Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId")); + + //Long scheduleId = request.scheduleId(); + //String username = request.username(); waitingQueueService.addWaitingQueue(scheduleId, username); System.out.println("연결 성공"); } diff --git a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java index b1ed8b5..f2032a2 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java +++ b/src/main/java/org/example/siljeun/domain/reservation/entity/Reservation.java @@ -70,7 +70,7 @@ public Reservation(User user, SeatScheduleInfo seatScheduleInfo) { public void updateTicketPrice(UpdatePriceRequest dto) { if (!StringUtils.isBlank(dto.ticketReceipt())) { - this.ticketReceipt = TicketReceipt.valueOf(dto.ticketReceipt()); + this.ticketReceipt = TicketReceipt.valueOf(dto.ticketReceipt().toUpperCase()); } if (!StringUtils.isBlank(dto.discount())) { this.discount = Discount.valueOf(dto.discount()); diff --git a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java index c862976..71b1296 100644 --- a/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java @@ -18,7 +18,7 @@ public class SeatScheduleInfoController { private final SeatScheduleInfoService seatScheduleInfoService; - @PostMapping("/seat-schedule-info/{seatScheduleInfoId}") + @PostMapping("/seat-schedule-infos/{seatScheduleInfoId}") public ResponseEntity selectSeat( @PathVariable Long seatScheduleInfoId, @AuthenticationPrincipal PrincipalDetails userDetails @@ -28,7 +28,7 @@ public ResponseEntity selectSeat( return ResponseEntity.ok("좌석이 선택되었습니다."); } - @GetMapping("/schedule/{scheduleId}/seat-schedule-info") + @GetMapping("/schedules/{scheduleId}/seat-schedule-infos") public ResponseEntity> getSeatScheduleInfos( @PathVariable Long scheduleId ) { From 707726efaa25c09ae14cd683565b05d7d70b8c2e Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Fri, 23 May 2025 13:09:09 +0900 Subject: [PATCH 32/32] =?UTF-8?q?chore=20:=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20text=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stomp_send_frame_with_null_octet.bin | Bin 0 -> 109 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/stomp_send_frame_with_null_octet.bin diff --git a/src/main/resources/stomp_send_frame_with_null_octet.bin b/src/main/resources/stomp_send_frame_with_null_octet.bin new file mode 100644 index 0000000000000000000000000000000000000000..585014f053b9be003fb9393429fa674cd4df29a5 GIT binary patch literal 109 zcmW-ZF$%*l3;;XpFR4jKuc=pukbXeOf;bq-#B!j&Z_@4Hj+^Ef!}I|p;%dXmN6RXH ze7<1QX1*w4xe4F_u!i`C#Lcpx-JQc)9j3H&?hK@2EH>D;vcF8p^v~ShRIb4f&pRa& literal 0 HcmV?d00001