From 5e22823b554814c1ca8a9e0d1fb59b4e1edcd15e Mon Sep 17 00:00:00 2001 From: pokerbearkr Date: Fri, 16 May 2025 10:00:41 +0900 Subject: [PATCH 01/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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 68da97fda7e17f5885d2066fe0de035147c29f16 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 14:26:57 +0900 Subject: [PATCH 15/53] =?UTF-8?q?build:=20.gitignore=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c2065bc..03c8c58 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +src/main/resources/application.yml ### STS ### .apt_generated From d508180d30613c531fae02980b91fc11d4f3e501 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 14:44:36 +0900 Subject: [PATCH 16/53] =?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 b15daca6c867ff71cac7d85dd09aac76659502f3 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 16:59:32 +0900 Subject: [PATCH 17/53] =?UTF-8?q?build:=20OAuth2=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 036b2ad..a73754c 100644 --- a/build.gradle +++ b/build.gradle @@ -36,9 +36,11 @@ dependencies { // auth implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + } tasks.named('test') { From 41c3467b204822ea03b9fc423ea7e90ee610a79a Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 16:59:54 +0900 Subject: [PATCH 18/53] =?UTF-8?q?style:=20DTO=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/siljeun/domain/auth/dto/LoginRequestDto.java | 5 ----- .../auth/dto/{LoginResponseDto.java => LoginResponse.java} | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java rename src/main/java/org/example/siljeun/domain/auth/dto/{LoginResponseDto.java => LoginResponse.java} (81%) diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java deleted file mode 100644 index 448ba4b..0000000 --- a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.auth.dto; - -public record LoginRequestDto(String username, String password) { - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java similarity index 81% rename from src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java rename to src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java index 1990ae2..640e04d 100644 --- a/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java +++ b/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public class LoginResponseDto { +public class LoginResponse { private final String token; From ff1cf7deff349d8f6f7949c9e82e3cf596814358 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 17:00:07 +0900 Subject: [PATCH 19/53] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 8 +- .../siljeun/domain/auth/dto/LoginRequest.java | 5 ++ .../domain/auth/service/AuthService.java | 9 +-- .../domain/oauth/client/KakaoApiClient.java | 73 +++++++++++++++++++ .../oauth/controller/OAuthController.java | 25 +++++++ .../domain/oauth/dto/KakaoUserInfo.java | 14 ++++ .../oauth/service/KakaoOAuthService.java | 41 +++++++++++ .../siljeun/domain/user/entity/User.java | 16 +++- .../user/repository/UserRepository.java | 3 + .../global/config/KakaoOAuthProperties.java | 19 +++++ .../global/config/RestTemplateConfig.java | 15 ++++ .../siljeun/global/config/SecurityConfig.java | 14 +++- .../security/CustomOAuth2SuccessHandler.java | 40 ++++++++++ .../JwtAuthenticationFilter.java | 2 +- .../global/{jwt => security}/JwtUtil.java | 2 +- src/main/resources/application.properties | 1 - 16 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java create mode 100644 src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java create mode 100644 src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java create mode 100644 src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java rename src/main/java/org/example/siljeun/global/{jwt => security}/JwtAuthenticationFilter.java (98%) rename src/main/java/org/example/siljeun/global/{jwt => security}/JwtUtil.java (97%) delete mode 100644 src/main/resources/application.properties diff --git a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java index b4acd09..e44d655 100644 --- a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java @@ -1,8 +1,8 @@ package org.example.siljeun.domain.auth.controller; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.auth.dto.LoginRequestDto; -import org.example.siljeun.domain.auth.dto.LoginResponseDto; +import org.example.siljeun.domain.auth.dto.LoginRequest; +import org.example.siljeun.domain.auth.dto.LoginResponse; import org.example.siljeun.domain.auth.service.AuthService; import org.example.siljeun.global.dto.ResponseDto; import org.springframework.stereotype.Controller; @@ -18,9 +18,9 @@ public class AuthController { private final AuthService authService; @PostMapping("/login") - public ResponseDto login(@RequestBody LoginRequestDto request) { + public ResponseDto login(@RequestBody LoginRequest request) { try { - LoginResponseDto response = authService.login(request.username(), request.password()); + LoginResponse response = authService.login(request.username(), request.password()); return ResponseDto.success("로그인 성공", response); } catch (Exception e) { return ResponseDto.fail("아이디 또는 비밀번호가 올바르지 않습니다."); diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..7a12f6a --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.auth.dto; + +public record LoginRequest(String username, String password) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java index 0c2cb48..a056f66 100644 --- a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java @@ -1,9 +1,8 @@ package org.example.siljeun.domain.auth.service; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.auth.dto.LoginResponseDto; -import org.example.siljeun.global.dto.ResponseDto; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.domain.auth.dto.LoginResponse; +import org.example.siljeun.global.security.JwtUtil; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; @@ -17,14 +16,14 @@ public class AuthService { private final AuthenticationManagerBuilder authManagerBuilder; private final JwtUtil jwtUtil; - public LoginResponseDto login(String username, String password) { + public LoginResponse login(String username, String password) { Authentication authentication = authManagerBuilder.getObject() .authenticate(new UsernamePasswordAuthenticationToken(username, password)); SecurityContextHolder.getContext().setAuthentication(authentication); String token = jwtUtil.createToken(username); - return new LoginResponseDto(token); + return new LoginResponse(token); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java new file mode 100644 index 0000000..48f2107 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java @@ -0,0 +1,73 @@ +package org.example.siljeun.domain.oauth.client; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; +import org.example.siljeun.global.config.KakaoOAuthProperties; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +public class KakaoApiClient { + + private final RestTemplate restTemplate; + private final KakaoOAuthProperties properties; + + public String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", properties.getClientId()); + params.add("redirect_uri", properties.getRedirectUri()); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity> response = restTemplate.exchange( + properties.getTokenUri(), + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() { + } + ); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new RuntimeException("카카오 Access Token 요청 실패"); + } + + return response.getBody().get("access_token").toString(); + } + + public KakaoUserInfo getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + properties.getUserInfoUri(), + HttpMethod.GET, + request, + KakaoUserInfo.class + ); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new RuntimeException("카카오 사용자 정보 요청 실패"); + } + + return response.getBody(); + } + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java new file mode 100644 index 0000000..ffc0e55 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java @@ -0,0 +1,25 @@ +package org.example.siljeun.domain.oauth.controller; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.service.KakaoOAuthService; +import org.example.siljeun.global.dto.ResponseDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/oauth") +public class OAuthController { + + private final KakaoOAuthService kakaoOAuthService; + + @GetMapping("/kakao/callback") + public ResponseEntity kakaoCallback(@RequestParam String code) { + String jwt = kakaoOAuthService.kakaoLogin(code); + return ResponseEntity.ok(jwt); + } + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java new file mode 100644 index 0000000..c1afefe --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java @@ -0,0 +1,14 @@ +package org.example.siljeun.domain.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class KakaoUserInfo { + + private Long id; + private String email; + private String nickname; + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java new file mode 100644 index 0000000..ffe393d --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java @@ -0,0 +1,41 @@ +package org.example.siljeun.domain.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.client.KakaoApiClient; +import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; +import org.example.siljeun.domain.user.entity.User; +import org.example.siljeun.domain.user.enums.Provider; +import org.example.siljeun.domain.user.repository.UserRepository; +import org.example.siljeun.global.security.JwtUtil; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final KakaoApiClient kakaoApiClient; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + public String kakaoLogin(String code) { + // 1. 액세스 토큰 요청 + String accessToken = kakaoApiClient.getAccessToken(code); + + // 2. 사용자 정보 요청 + KakaoUserInfo userInfo = kakaoApiClient.getUserInfo(accessToken); + + // 3. 회원 가입 또는 로그인 처리 + User user = userRepository.findByEmail(userInfo.getEmail()) + .orElseGet(() -> registerUser(userInfo)); + + // 4. JWT 토큰 발급 + return jwtUtil.createToken(user.getUsername()); + } + + private User registerUser(KakaoUserInfo userInfo) { + User user = new User(userInfo.getEmail(), userInfo.getNickname(), Provider.KAKAO, + userInfo.getId()); + return userRepository.save(user); + } + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/user/entity/User.java b/src/main/java/org/example/siljeun/domain/user/entity/User.java index ac05501..bf4d338 100644 --- a/src/main/java/org/example/siljeun/domain/user/entity/User.java +++ b/src/main/java/org/example/siljeun/domain/user/entity/User.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDateTime; +import lombok.Builder; import lombok.Getter; import org.example.siljeun.domain.user.enums.Provider; import org.example.siljeun.domain.user.enums.Role; @@ -32,7 +33,10 @@ public class User extends BaseEntity { @Column(nullable = false, length = 255) private String password; - @Column(nullable = false, length = 255) + @Column(nullable = false, length = 10) + private String nickname; + + @Column(length = 255) private String address; @Enumerated(EnumType.STRING) @@ -43,9 +47,15 @@ public class User extends BaseEntity { @Column(nullable = false) private Provider provider; - @Column(name = "provider_id", length = 255) - private String providerId; + private Long providerId; private LocalDateTime deletedAt; + public User(String email, String nickname, Provider provider, Long providerId) { + this.email = email; + this.nickname = nickname; + this.provider = provider; + this.providerId = providerId; + } + } \ No newline at end of file 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 bb14960..6a5ec64 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 @@ -7,4 +7,7 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + + Optional findByEmail(String email); + } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java b/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java new file mode 100644 index 0000000..54a47f4 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java @@ -0,0 +1,19 @@ +package org.example.siljeun.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "kakao") +public class KakaoOAuthProperties { + + private String clientId; + private String redirectUri; + private String tokenUri; + private String userInfoUri; + +} diff --git a/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java b/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..d8c7359 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package org.example.siljeun.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} 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 5068c7a..8976d6a 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -2,8 +2,9 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.user.service.CustomUserDetailsService; -import org.example.siljeun.global.jwt.JwtAuthenticationFilter; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.global.security.CustomOAuth2SuccessHandler; +import org.example.siljeun.global.security.JwtAuthenticationFilter; +import org.example.siljeun.global.security.JwtUtil; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -19,17 +20,24 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() .anyRequest().authenticated() ) + .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/security/CustomOAuth2SuccessHandler.java b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java new file mode 100644 index 0000000..f4f70c2 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java @@ -0,0 +1,40 @@ +package org.example.siljeun.global.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.security.oauth2.core.user.OAuth2User; + + +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + // 1. principal에서 사용자 정보 추출 + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String username = "kakao_" + oAuth2User.getAttribute("id").toString(); + + // 2. JWT 생성 + String token = jwtUtil.createToken(username); + + // 3. JSON 응답 세팅 + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + + // 4. JWT를 JSON 응답 바디로 내려주기 + String json = String.format("{\"token\":\"%s\"}", token); + response.getWriter().write(json); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java b/src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java similarity index 98% rename from src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java rename to src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java index 31439ce..e8334a3 100644 --- a/src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.example.siljeun.global.jwt; +package org.example.siljeun.global.security; import jakarta.annotation.Nonnull; import jakarta.servlet.FilterChain; diff --git a/src/main/java/org/example/siljeun/global/jwt/JwtUtil.java b/src/main/java/org/example/siljeun/global/security/JwtUtil.java similarity index 97% rename from src/main/java/org/example/siljeun/global/jwt/JwtUtil.java rename to src/main/java/org/example/siljeun/global/security/JwtUtil.java index adad086..fe6090f 100644 --- a/src/main/java/org/example/siljeun/global/jwt/JwtUtil.java +++ b/src/main/java/org/example/siljeun/global/security/JwtUtil.java @@ -1,4 +1,4 @@ -package org.example.siljeun.global.jwt; +package org.example.siljeun.global.security; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 272c2a3..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=siljeun From ccc5362b2ca700293e67873ce84dd43c07473dbb Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Wed, 21 May 2025 17:50:45 +0900 Subject: [PATCH 20/53] 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 21/53] =?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 22/53] =?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 23/53] =?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 24/53] =?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 25/53] =?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 26/53] =?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 27/53] =?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 28/53] =?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 20ef6efb486e6c20f8616b2a0b358b852b23995f Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Thu, 22 May 2025 14:29:31 +0900 Subject: [PATCH 29/53] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=20=EC=9E=A1?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/oauth/client/NaverApiClient.java | 5 +++++ .../domain/oauth/dto/NaverUserInfo.java | 5 +++++ .../oauth/service/NaverOAuthService.java | 8 ++++++++ .../global/config/NaverOAuthProperties.java | 20 +++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java create mode 100644 src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java new file mode 100644 index 0000000..88c1713 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.oauth.client; + +public class NaverApiClient { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java new file mode 100644 index 0000000..3aff8c2 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.oauth.dto; + +public class NaverUserInfo { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java new file mode 100644 index 0000000..0fec45e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.oauth.service; + +import org.springframework.stereotype.Service; + +@Service +public class NaverOAuthService { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java b/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java new file mode 100644 index 0000000..2ca3fbc --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java @@ -0,0 +1,20 @@ +package org.example.siljeun.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "naver") +public class NaverOAuthProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; + private String tokenUri; + private String userInfoUri; + +} \ No newline at end of file From 68e8de8610263e40717abf7197a7b6b5cad2fe47 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Thu, 22 May 2025 15:52:18 +0900 Subject: [PATCH 30/53] =?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 31/53] =?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 32/53] =?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 33/53] =?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 34/53] =?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 35/53] =?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 36/53] =?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 37/53] =?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 From e3fc7a0f0a83d793a26b127f008fab0368405b03 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Fri, 23 May 2025 21:52:35 +0900 Subject: [PATCH 38/53] =?UTF-8?q?refactor=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=95=A0=EB=A7=A4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 9 ---- .../service/ReservationService.java | 41 ------------------- 2 files changed, 50 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 02e9343..34b1f58 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 @@ -44,13 +44,4 @@ public ResponseEntity> findById( ReservationInfoResponse dto = reservationService.findById(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 조회 성공", dto)); } - - @PostMapping() - public ResponseEntity> createReservation( - @RequestBody @Valid ReservationCreateRequest reservationCreateRequest, - @AuthenticationPrincipal PrincipalDetails userDetails - ){ - reservationService.createReservation(reservationCreateRequest, userDetails.getUserId()); - return ResponseEntity.ok(ResponseDto.success("결제 진행하기", null)); - } } 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 23c38b2..0aac498 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 @@ -87,45 +87,4 @@ public ReservationInfoResponse findById(String username, Long reservationId) { return ReservationInfoResponse.from(reservation); } - - @Transactional - public void createReservation(ReservationCreateRequest reservationCreateRequest, Long userId){ - - //유저 확인 - User user = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다.")); - - Long scheduleId = reservationCreateRequest.scheduleId(); - //유저가 해당 회차에 선택한 좌석 검증 - String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; - log.info("예매 정보 생성 시도 user : " + userId + "scheduleId : " + scheduleId + " key) " + redisSelectedKey ); - String selectedId = redisTemplate.opsForValue().get(redisSelectedKey); - - if (selectedId == null) { - throw new IllegalStateException("선택한 좌석이 없습니다."); - } - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(Long.valueOf(selectedId)) - .orElseThrow(() -> new EntityNotFoundException("좌석 정보를 찾을 수 없습니다.")); - - //해당 좌석의 상태 검증 - String redisStatusHashKey = "seatStatus:" + scheduleId; - Object redisStatusObj = redisTemplate.opsForHash().get(redisStatusHashKey, selectedId); - - if (redisStatusObj == null || !redisStatusObj.toString().equals(SeatStatus.SELECTED.name())) { - throw new IllegalStateException("좌석 상태가 유효하지 않습니다. 다시 선택해주세요."); - } - - //예매 정보 생성 - Reservation reservation = new Reservation(user, seatScheduleInfo); - reservationRepository.save(reservation); - - //좌석 상태 결제 진행 중으로 변경 - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.HOLD); - seatScheduleInfoRepository.save(seatScheduleInfo); - redisTemplate.opsForHash().put(redisStatusHashKey, selectedId, SeatStatus.HOLD.name()); - - //유저가 선점한 좌석 정보 - 결제 진행 상태일 때의 만료 시간 1시간 - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(60)); - } } From 1ceb711409a762a2709e22e58d7d02f43c2100b7 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Fri, 23 May 2025 23:55:11 +0900 Subject: [PATCH 39/53] =?UTF-8?q?feat=20:=20SeateSchedulerInfo=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C,=20redisKey=20=EC=83=9D=EC=84=B1=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SeatScheduleInfoService.java | 33 ++++++++++++------- .../siljeun/global/util/RedisKeyProvider.java | 11 +++++++ 2 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 56cc1d9..d0b1ce9 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -11,9 +11,11 @@ import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.global.lock.DistributedLock; +import org.example.siljeun.global.util.RedisKeyProvider; 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; @@ -37,20 +39,17 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - log.info("예매 미오픈."); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); } if (!seatScheduleInfo.isAvailable()) { - log.info("이미 선점된 좌석입니다."); throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); } seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); seatScheduleInfoRepository.save(seatScheduleInfo); - //userId와 schedule Id가 key이고 seatSchduleInfoId로 구성된 value인 형태로 저장 - String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; + String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); @@ -59,10 +58,8 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { redisTemplate.opsForValue().set(redisSelectedKey, seatScheduleInfoId.toString()); redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - //seatScheduleInfoId의 seatStatus 상태 변경 - String redisHashKey = "seatStatus:" + scheduleId; - redisTemplate.opsForHash().put(redisHashKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); - log.info("redisHashKey : " + redisHashKey + " = " + " redisSelectedKey : " + redisSelectedKey); + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + redisTemplate.opsForHash().put(redisKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); } public Map getSeatStatusMap(Long scheduleId) { @@ -77,9 +74,8 @@ public Map getSeatStatusMap(Long scheduleId) { .map(info -> info.getId().toString()) .toList(); - String redisHashKey = "seatStatus:" + scheduleId; - List redisStatuses = redisTemplate.opsForHash().multiGet(redisHashKey, new ArrayList<>(fieldKeys)); - + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); Map seatStatusMap = new HashMap<>(); for (int i = 0; i < seatScheduleInfos.size(); i++) { @@ -102,7 +98,7 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - String redisHashKey = "seatStatus:" + schedule.getId(); + String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); Map seatStatusMap = new HashMap<>(); for (SeatScheduleInfo seat : seatInfos) { @@ -111,4 +107,17 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } + + @Transactional + public void updateSeatSchedulerInfoStatus(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + String fieldKey = seatScheduleInfoId.toString(); + + String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + } } diff --git a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java new file mode 100644 index 0000000..cd33c65 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java @@ -0,0 +1,11 @@ +package org.example.siljeun.global.util; + +public class RedisKeyProvider { + public static String seatStatusKey(Long scheduleId){ + return "seatStatus:" + scheduleId; + } + + public static String userSelectedSeatKey(Long userId, Long scheduleId){ + return "user:"+userId+":scheduleSelected:"+scheduleId; + } +} From 1c327750f2ebc867a1143d2d546a7701614c44c2 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 00:41:24 +0900 Subject: [PATCH 40/53] =?UTF-8?q?refactor=20:=20=ED=9A=8C=EC=B0=A8?= =?UTF-8?q?=EB=B3=84=20=EC=A2=8C=EC=84=9D=20=EC=83=81=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 5 +++++ .../service/ReservationService.java | 2 +- .../SeatScheduleInfoController.java | 22 ++++++++++++------- .../service/SeatScheduleInfoService.java | 10 +++++---- 4 files changed, 26 insertions(+), 13 deletions(-) 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 3f778fd..d1bac49 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 @@ -5,6 +5,8 @@ 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.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,7 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final ReservationService reservationService; + private final SeatScheduleInfoService seatScheduleInfoService; @Transactional public void savePayment(PaymentConfirmRequestDto dto) { @@ -24,6 +27,8 @@ public void savePayment(PaymentConfirmRequestDto dto) { .build(); paymentRepository.save(payment); + + seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } 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 0aac498..fca72f8 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 @@ -72,7 +72,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.CANCELLED); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java index e19e85f..da72620 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java @@ -1,27 +1,25 @@ package org.example.siljeun.domain.seatscheduleinfo.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.seatscheduleinfo.dto.request.SeatScheduleUpdateStatusRequest; import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.example.siljeun.global.dto.ResponseDto; 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 org.springframework.web.bind.annotation.*; import java.util.Map; @Controller @RequiredArgsConstructor -@RequestMapping("/schedules/{scheduleId}") public class SeatScheduleInfoController { private final SeatScheduleInfoService seatScheduleInfoService; - @PostMapping("/seat-schedule-infos") + @PostMapping("/schedules/{scheduleId}/seat-schedule-infos") public ResponseEntity> forceSeatScheduleInfoInRedis( @PathVariable Long scheduleId ) @@ -30,7 +28,7 @@ public ResponseEntity> forceSeatScheduleInfoInRedis( return ResponseEntity.ok(ResponseDto.success("Redis 적재 완료 scheduleId : " + scheduleId, null)); } - @PostMapping("/seat-schedule-infos/{seatScheduleInfoId}") + @PostMapping("/schedules/{scheduleId}/seat-schedule-infos/{seatScheduleInfoId}") public ResponseEntity> selectSeat( @PathVariable Long scheduleId, @PathVariable Long seatScheduleInfoId, @@ -40,10 +38,18 @@ public ResponseEntity> selectSeat( return ResponseEntity.ok(ResponseDto.success( "좌석이 선택되었습니다. seatScheduleInfoId : " + seatScheduleInfoId.toString(), null)); } - @GetMapping("/seat-schedule-infos") + @GetMapping("/schedules/{scheduleId}/seat-schedule-infos") public ResponseEntity> getSeatScheduleInfos( @PathVariable Long scheduleId ) { return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); } + + @PatchMapping("/seat-schedule-infos") + public ResponseEntity> updateSeatScheduleInfoStatus( + @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest + ){ + seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); + return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); + } } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index d0b1ce9..bb1b661 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -109,15 +109,17 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ } @Transactional - public void updateSeatSchedulerInfoStatus(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + public void updateSeatSchedulerInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); } } From aec9f1f5bcf974c69fb2d2b312360452ffec9ee5 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Sat, 24 May 2025 14:01:39 +0900 Subject: [PATCH 41/53] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 3 + .../domain/auth/service/AuthService.java | 4 +- ...aoApiClient.java => KakaoOAuthClient.java} | 56 +++++++++--------- .../domain/oauth/client/NaverApiClient.java | 5 -- .../oauth/controller/OAuthController.java | 16 +++-- .../domain/oauth/dto/KakaoAccessToken.java | 13 ++++ .../domain/oauth/dto/KakaoAccount.java | 12 ++++ .../domain/oauth/dto/KakaoProfile.java | 12 ++++ .../domain/oauth/dto/KakaoUserInfo.java | 45 +++----------- .../domain/oauth/dto/NaverUserInfo.java | 5 -- .../domain/oauth/dto/OAuth2UserInfo.java | 17 ------ .../oauth/service/KakaoOAuthService.java | 59 +++++++++++++++---- .../oauth/service/NaverOAuthService.java | 8 --- .../siljeun/domain/user/entity/User.java | 16 +++-- .../siljeun/global/config/SecurityConfig.java | 15 +++-- .../security/CustomOAuth2SuccessHandler.java | 7 +++ .../global/security/PrincipalDetails.java | 7 ++- 17 files changed, 168 insertions(+), 132 deletions(-) rename src/main/java/org/example/siljeun/domain/oauth/client/{KakaoApiClient.java => KakaoOAuthClient.java} (50%) delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java diff --git a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java index 0ee8150..24d458e 100644 --- a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.auth.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.auth.dto.request.LoginRequest; import org.example.siljeun.domain.auth.dto.request.SignUpRequest; import org.example.siljeun.domain.auth.dto.response.LoginResponse; @@ -17,6 +18,7 @@ @RestController @RequestMapping("/auth") @RequiredArgsConstructor +@Slf4j public class AuthController { private final AuthService authService; @@ -33,6 +35,7 @@ public ResponseEntity signUp(@RequestBody SignUpRequest request) @PostMapping("/login") public ResponseDto login(@RequestBody LoginRequest request) { try { + log.debug("----- 로그인 메서드 실행 -----"); LoginResponse response = authService.login(request.username(), request.password()); return ResponseDto.success("로그인 성공", response); } catch (Exception e) { diff --git a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java index 98cbc21..e17f34b 100644 --- a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java @@ -24,8 +24,8 @@ public SignUpResponse signUp(SignUpRequest request) { String password = passwordEncoder.encode(request.password()); // 회원 생성 및 저장 - User user = new User(request.email(), request.username(), password, request.nickname(), - request.role(), request.provider()); + User user = new User(request.email(), request.username(), password, request.name(), + request.nickname(), request.role(), request.provider()); User savedUser = userRepository.save(user); return new SignUpResponse(savedUser.getId(), savedUser.getEmail(), savedUser.getUsername()); diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java similarity index 50% rename from src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java rename to src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java index 3d1f01a..f76628c 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java +++ b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java @@ -1,16 +1,13 @@ package org.example.siljeun.domain.oauth.client; -import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.oauth.dto.KakaoAccessToken; import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; -import org.example.siljeun.global.config.KakaoOAuthProperties; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -18,56 +15,57 @@ @Component @RequiredArgsConstructor -public class KakaoApiClient { +@Slf4j +public class KakaoOAuthClient { private final RestTemplate restTemplate; - private final KakaoOAuthProperties properties; + // 현재 카카오 API 서버에서 인가 코드를 제공한 상태이다 + // 서비스 서버가 인가 코드를 이용해 카카오 API 서버로 액세스 토큰을 요청한다 public String getAccessToken(String code) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap params = new LinkedMultiValueMap<>(); + // 아래 4가지 값은 필수 params.add("grant_type", "authorization_code"); - params.add("client_id", "eaee0e144aeb9afef54d5c449448baea"); - params.add("redirect_uri", "http://localhost:8080/oauth/kakao/callback"); - params.add("code", code); + params.add("client_id", "eaee0e144aeb9afef54d5c449448baea"); // 카카오 REST API 키 + params.add("redirect_uri", "http://localhost:8080/oauth/kakao/callback"); // 여기서 문제 발생? + params.add("code", code); // 인가 코드 HttpEntity> request = new HttpEntity<>(params, headers); - ResponseEntity> response = restTemplate.exchange( + // 명시한 URL로 (인가 코드를 담은) POST 요청을 보내면 카카오 API 서버에서 액세스 토큰을 응답한다 + KakaoAccessToken response = restTemplate.postForEntity( "https://kauth.kakao.com/oauth/token", - HttpMethod.POST, request, - new ParameterizedTypeReference<>() { - } - ); + KakaoAccessToken.class + ).getBody(); - if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { - throw new RuntimeException("카카오 Access Token 요청 실패"); - } + log.debug("----- 액세스 토큰: {} -----", response.accessToken()); - return response.getBody().get("access_token").toString(); + return response.accessToken(); } + // 서비스 서버가 카카오 인증 서버에 저장된 회원 정보를 요청한다 public KakaoUserInfo getUserInfo(String accessToken) { - HttpHeaders headers = new HttpHeaders(); + // HTTP 헤더 설정 + final HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - HttpEntity request = new HttpEntity<>(headers); + // 설정한 HTTP 헤더를 이용해 요청 생성 + final HttpEntity request = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange( + // GET 메서드로 회원 정보를 요청한 후 KakaoUserInfo 객체에 담음 + final KakaoUserInfo response = restTemplate.exchange( "https://kapi.kakao.com/v2/user/me", HttpMethod.GET, request, KakaoUserInfo.class - ); - - if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { - throw new RuntimeException("카카오 사용자 정보 요청 실패"); - } + ).getBody(); - return response.getBody(); + return response; } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java deleted file mode 100644 index 88c1713..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.oauth.client; - -public class NaverApiClient { - -} diff --git a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java index ffc0e55..4c11678 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java +++ b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java @@ -1,8 +1,9 @@ package org.example.siljeun.domain.oauth.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.auth.dto.response.LoginResponse; import org.example.siljeun.domain.oauth.service.KakaoOAuthService; -import org.example.siljeun.global.dto.ResponseDto; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -12,14 +13,21 @@ @RestController @RequiredArgsConstructor @RequestMapping("/oauth") +@Slf4j public class OAuthController { private final KakaoOAuthService kakaoOAuthService; + /* + 1. 클라이언트가 카카오 로그인을 요청한다 + 2. /oauth/kakao/callback?code={code}로 리다이렉트된다 + 3. 이때 카카오에서 쿼리 스트링으로 인가 코드를 넘겨준다 + 4. 넘어온 인가 코드를 이용해서 카카오 로그인 API를 호출한다 + */ @GetMapping("/kakao/callback") - public ResponseEntity kakaoCallback(@RequestParam String code) { - String jwt = kakaoOAuthService.kakaoLogin(code); - return ResponseEntity.ok(jwt); + public ResponseEntity kakaoCallback(@RequestParam String code) { + log.debug("---------- METHOD: kakaoCallback ----------"); + return ResponseEntity.ok(kakaoOAuthService.kakaoLogin(code)); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java new file mode 100644 index 0000000..ad8ad55 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java @@ -0,0 +1,13 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoAccessToken(String tokenType, + String accessToken, + Integer expiresIn, + String refreshToken, + Integer refreshTokenExpiresIn) { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java new file mode 100644 index 0000000..0f271d1 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java @@ -0,0 +1,12 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoAccount( + KakaoProfile profile, // 프로필 정보(닉네임, 프로필 사진) + String email +) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java new file mode 100644 index 0000000..c85a06c --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java @@ -0,0 +1,12 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoProfile( + String nickname, + String profileImageUrl +) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java index f5c855f..f6660c1 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java @@ -1,41 +1,12 @@ package org.example.siljeun.domain.oauth.dto; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class KakaoUserInfo implements OAuth2UserInfo { - - private Map attributes; - private Map attributesAccount; - private Map attributesProfile; - - public KakaoUserInfo(Map attributes) { - this.attributes = attributes; - this.attributesAccount = (Map) attributes.get("kakao_account"); - this.attributesProfile = (Map) attributesAccount.get("profile"); - } - - @Override - public String getProvider() { - return "Kakao"; - } - - @Override - public String getProviderId() { - return attributes.get("id").toString(); - } - - @Override - public String getEmail() { - return attributesAccount.get("email").toString(); - } - - @Override - public String getNickname() { - return attributesProfile.get("nickname").toString(); - } +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoUserInfo( + Long id, // 회원 번호 + KakaoAccount kakaoAccount // 카카오 계정 정보 +) { } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java deleted file mode 100644 index 3aff8c2..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.oauth.dto; - -public class NaverUserInfo { - -} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java deleted file mode 100644 index e47bd09..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.example.siljeun.domain.oauth.dto; - -import java.util.Map; - -public interface OAuth2UserInfo { - - public Map getAttributes(); - - String getProvider(); - - String getProviderId(); - - String getEmail(); - - String getNickname(); - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java index 8776f1c..cbd4cb0 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java +++ b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java @@ -1,40 +1,73 @@ package org.example.siljeun.domain.oauth.service; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.oauth.client.KakaoApiClient; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.auth.dto.response.LoginResponse; +import org.example.siljeun.domain.oauth.client.KakaoOAuthClient; import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.enums.Provider; +import org.example.siljeun.domain.user.enums.Role; import org.example.siljeun.domain.user.repository.UserRepository; import org.example.siljeun.global.security.JwtUtil; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@Slf4j public class KakaoOAuthService { - private final KakaoApiClient kakaoApiClient; + private final KakaoOAuthClient kakaoOAuthClient; private final UserRepository userRepository; private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; - public String kakaoLogin(String code) { - // 1. 액세스 토큰 요청 - String accessToken = kakaoApiClient.getAccessToken(code); + // 인가 코드를 이용해 카카오 로그인 API를 호출한다 + public LoginResponse kakaoLogin(String code) { + // 1. 카카오에 인가 코드를 넘겨서 액세스 토큰을 획득한다 + log.debug("----- 액세스 토큰 발급 -----"); + final String accessToken = kakaoOAuthClient.getAccessToken(code); - // 2. 사용자 정보 요청 - KakaoUserInfo userInfo = kakaoApiClient.getUserInfo(accessToken); + // 2. 카카오에 액세스 토큰을 넘겨서 카카오에 저장된 사용자 정보를 획득한다 + log.debug("----- 사용자 정보 획득 -----"); + final KakaoUserInfo userInfo = kakaoOAuthClient.getUserInfo(accessToken); - // 3. 회원 가입 또는 로그인 처리 - User user = userRepository.findByEmail(userInfo.getEmail()) + // 3. 해당 정보를 이용해 회원 가입 또는 로그인을 처리한다 + log.debug("----- 회원 가입 또는 로그인 -----"); + User user = userRepository.findByEmail(userInfo.kakaoAccount().email()) .orElseGet(() -> registerUser(userInfo)); - // 4. JWT 토큰 발급 - return jwtUtil.createToken(user.getUsername()); + // 4. 서비스 서버에 저장된 회원 정보를 이용해 JWT 토큰을 발급받는다 + log.debug("----- JWT 토큰 발급 -----"); + String token = jwtUtil.createToken(user.getUsername()); + + return new LoginResponse(token); } private User registerUser(KakaoUserInfo userInfo) { - User user = new User(userInfo.getEmail(), userInfo.getNickname(), Provider.KAKAO, - userInfo.getProviderId()); + String username = "kakao" + userInfo.id(); + String password = passwordEncoder.encode(username); + User user = new User( + userInfo.kakaoAccount().email(), + username, + password, + userInfo.kakaoAccount().profile().nickname(), + userInfo.kakaoAccount().profile().nickname(), + Role.USER, + Provider.KAKAO, + userInfo.id() + ); + log.debug("--------------------회원 가입 메서드 실행--------------------"); + log.debug("email: {}, username: {}, password: {}, name: {}, nickname: {}, id: {}", + userInfo.kakaoAccount().email(), + username, + password, + userInfo.kakaoAccount().profile().nickname(), + userInfo.kakaoAccount().profile().nickname(), + userInfo.id() + ); + return userRepository.save(user); } diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java deleted file mode 100644 index 0fec45e..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.example.siljeun.domain.oauth.service; - -import org.springframework.stereotype.Service; - -@Service -public class NaverOAuthService { - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/user/entity/User.java b/src/main/java/org/example/siljeun/domain/user/entity/User.java index 14ec94f..575d8c1 100644 --- a/src/main/java/org/example/siljeun/domain/user/entity/User.java +++ b/src/main/java/org/example/siljeun/domain/user/entity/User.java @@ -51,23 +51,31 @@ public class User extends BaseEntity { @Column(nullable = false) private Provider provider; - private String providerId; + private Long providerId; private LocalDateTime deletedAt; - public User(String email, String username, String password, String nickname, Role role, - Provider provider) { + // 로컬 회원 가입용 생성자 + public User(String email, String username, String password, String name, String nickname, + Role role, Provider provider) { this.email = email; this.username = username; this.password = password; + this.name = name; this.nickname = nickname; this.role = role; this.provider = provider; } - public User(String email, String nickname, Provider provider, String providerId) { + // 소셜 회원 가입용 생성자 + public User(String email, String username, String password, String name, String nickname, + Role role, Provider provider, Long providerId) { this.email = email; + this.username = username; + this.password = password; + this.name = name; this.nickname = nickname; + this.role = role; this.provider = provider; this.providerId = providerId; } 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 9afd186..70a80e3 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -31,14 +31,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth2/**", "/login/**", "/ws/**", "/ws","/checkout.html","/payments","/success.html").permitAll() + .requestMatchers("/auth/**", "/oauth/**", "/oauth2/**", "/login/**", "/ws/**", "/ws", + "/checkout.html", "/payments", "/success.html").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") + .failureUrl("/auth/oauth2/failure") + ) +// .formLogin(form -> form +// .loginPage("/login")) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java index dff18e0..8a32da3 100644 --- a/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java +++ b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ @Component @RequiredArgsConstructor +@Slf4j public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtUtil jwtUtil; @@ -21,6 +23,8 @@ public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.debug("----- 로그인 성공 -----"); + // principal에서 사용자 정보 추출 OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); String username = "kakao_" + oAuth2User.getAttribute("id").toString(); @@ -41,6 +45,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setStatus(HttpServletResponse.SC_OK); response.getWriter().write("{\"message\": \"Login successful\"}"); response.getWriter().flush(); + + // 기본 경로로 리다이렉트 + response.sendRedirect("/"); } } diff --git a/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java b/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java index 40c0d2a..dc7e71c 100644 --- a/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java +++ b/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java @@ -2,12 +2,14 @@ import java.util.Collection; import java.util.List; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.user.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +@Getter @RequiredArgsConstructor public class PrincipalDetails implements UserDetails { @@ -49,7 +51,8 @@ public boolean isCredentialsNonExpired() { @Override public boolean isEnabled() { - return user.getDeletedAt() != null; + // 삭제되지 않은 계정은 모두 활성화된 것으로 취급 + return user.getDeletedAt() == null; } -} +} \ No newline at end of file From 94f37d4e38c5fca5c18b720e9ee03c33fc330dac Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 17:41:56 +0900 Subject: [PATCH 42/53] =?UTF-8?q?feat=20:=20TTL=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationService.java | 2 +- .../siljeun/domain/seat/enums/SeatStatus.java | 2 +- .../domain/seat/service/SeatService.java | 2 +- .../service/SeatScheduleInfoService.java | 74 +++++++++++++------ .../siljeun/global/util/RedisKeyProvider.java | 14 +++- 5 files changed, 69 insertions(+), 25 deletions(-) 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 fca72f8..0aac498 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 @@ -72,7 +72,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.CANCELLED); + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java b/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java index 9941c06..9bd5302 100644 --- a/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java +++ b/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java @@ -2,7 +2,7 @@ public enum SeatStatus { BLOCKED, //미판매 - CANCELLED, //취소 + CANCELLED, //취소 - 취소표는 특정 시간대에 한 번에 풀어놓는 상태를 고려하여 넣어놓았으나 현재 구현 상태에서는 사용하지 않음 RESERVED, //예매됨 HOLD, //결제 진행 중 SELECTED, //선택됨 diff --git a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java index 13e60b3..1a3e3f8 100644 --- a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java +++ b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java @@ -15,12 +15,12 @@ @Service @RequiredArgsConstructor +@Transactional public class SeatService { private final VenueRepository venueRepository; private final SeatRepository seatRepository; - @Transactional public void createSeats(Long venueId, List seatCreateRequests){ Venue venue = venueRepository.findById(venueId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 공연장을 찾을 수 없습니다.")); //Throw 예외 설정 필요 diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index bb1b661..21d3a13 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -33,11 +33,11 @@ public class SeatScheduleInfoService { @DistributedLock(key = "'seat:' + #seatScheduleInfoId") public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { + //예외 상황 처리 SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); Schedule schedule = seatScheduleInfo.getSchedule(); - if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); } @@ -46,20 +46,29 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); } - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); - String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); - if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); } - redisTemplate.opsForValue().set(redisSelectedKey, seatScheduleInfoId.toString()); + //DB 상태 변경 + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); + + //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) + redisTemplate.opsForValue() + .set(redisSelectedKey, seatScheduleInfoId.toString()); redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - redisTemplate.opsForHash().put(redisKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); + //TTL 관리를 위한 키 생성 + String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + redisTemplate.opsForValue().set(redisLockKey, userId.toString()); + + //Redis 상태 변경 + updateSeatSchedulerInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + + //TTL 적용 + applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); } public Map getSeatStatusMap(Long scheduleId) { @@ -67,8 +76,10 @@ public Map getSeatStatusMap(Long scheduleId) { Schedule schedule = scheduleRepository.findById(scheduleId) .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); - List seatScheduleInfos = - seatScheduleInfoRepository.findAllBySchedule(schedule); + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + if(seatScheduleInfos.isEmpty()){ + throw new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다."); + } List fieldKeys = seatScheduleInfos.stream() .map(info -> info.getId().toString()) @@ -76,8 +87,8 @@ public Map getSeatStatusMap(Long scheduleId) { String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); - Map seatStatusMap = new HashMap<>(); + Map seatStatusMap = new HashMap<>(); for (int i = 0; i < seatScheduleInfos.size(); i++) { SeatScheduleInfo info = seatScheduleInfos.get(i); Object redisStatusObj = redisStatuses.get(i); @@ -108,18 +119,39 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } - @Transactional - public void updateSeatSchedulerInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); - - Long scheduleId = seatScheduleInfo.getSchedule().getId(); + public void updateSeatSchedulerInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); + redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); + } - String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); - - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ + String member = seatScheduleInfoId.toString(); + + String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); + String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); + + Duration ttl = null; + long nowMillis = System.currentTimeMillis(); + + redisTemplate.opsForZSet().remove(zsetSelectedKey, member); + redisTemplate.opsForZSet().remove(zsetHoldKey, member); + + switch(seatStatus){ + case SELECTED: + ttl = Duration.ofMinutes(5); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); + break; + case HOLD: + ttl = Duration.ofMinutes(60); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis+ttl.toMillis()); + break; + default: + redisTemplate.persist(seatLockkey); + break; + } } } diff --git a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java index cd33c65..1d5f31f 100644 --- a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java +++ b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java @@ -1,11 +1,23 @@ package org.example.siljeun.global.util; public class RedisKeyProvider { + + //회차에 따른 회차별 좌석 정보 Id와 상태 public static String seatStatusKey(Long scheduleId){ return "seatStatus:" + scheduleId; } + //유저가 선점한 특정 회차의 좌석 상태 정보 Id public static String userSelectedSeatKey(Long userId, Long scheduleId){ - return "user:"+userId+":scheduleSelected:"+scheduleId; + return "user:"+userId+":schedule:"+scheduleId; + } + + //회차별 좌석 상태 정보 점유중 + public static String seatOccupyKey(Long seatScheduleInfoId){ + return "seat:occupy:"+seatScheduleInfoId; + } + + public static String trackExpiresKey(String status){ + return "expires:"+status; } } From 7d9f41611349e3d5a4e4098d06a291dba9312617 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 19:51:14 +0900 Subject: [PATCH 43/53] =?UTF-8?q?feat=20:=20TTL=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 2 +- .../service/ReservationService.java | 3 - .../SeatScheduleInfoController.java | 14 +-- .../scheduler/SeatExpirationScheduler.java | 87 +++++++++++++++++++ .../siljeun/global/config/RedisConfig.java | 2 + 5 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java 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 d1bac49..adfba62 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 @@ -28,7 +28,7 @@ public void savePayment(PaymentConfirmRequestDto dto) { paymentRepository.save(payment); - seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + //seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } 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 0aac498..603479d 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 @@ -15,7 +15,6 @@ 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.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,8 +29,6 @@ public class ReservationService { private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final RedisTemplate redisTemplate; - @Transactional public void save(Long userId, Long seatScheduleInfoId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java index da72620..f7e8a2d 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java @@ -45,11 +45,11 @@ public ResponseEntity> getSeatScheduleInfos( return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); } - @PatchMapping("/seat-schedule-infos") - public ResponseEntity> updateSeatScheduleInfoStatus( - @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest - ){ - seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); - return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); - } +// @PatchMapping("/seat-schedule-infos") +// public ResponseEntity> updateSeatScheduleInfoStatus( +// @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest +// ){ +// seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); +// return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); +// } } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java new file mode 100644 index 0000000..15115cd --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java @@ -0,0 +1,87 @@ +package org.example.siljeun.domain.seatscheduleinfo.scheduler; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.global.util.RedisKeyProvider; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class SeatExpirationScheduler { + + private final RedisTemplate redisTemplate; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + + @Scheduled(fixedDelay = 60_000) + public void expireSeatsToAvailable() { + long now = System.currentTimeMillis(); + expireByStatus(SeatStatus.SELECTED, now); + expireByStatus(SeatStatus.HOLD, now); + } + + private void expireByStatus(SeatStatus status, long nowMillis) { + String zsetKey = RedisKeyProvider.trackExpiresKey(status.name()); + //상태-> 좌석Id들 1, 2, 3, 4,..... + 만료 시간 + //중에서 만료 시간이 지금 이전인 것들 조회 + Set expiredIds = redisTemplate + .opsForZSet() + .rangeByScore(zsetKey, 0, nowMillis); + if (expiredIds == null || expiredIds.isEmpty()) { + + return; + } + + //만료 시간이 지난 Id들을 Long 타입으로 변경하고 실제 객체를 가져와서 상태를 변경 후 저장 + List ids = expiredIds.stream() + .map(Long::valueOf) + .toList(); + List infos = seatScheduleInfoRepository.findAllById(ids); + infos.forEach(info -> info.updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE)); + seatScheduleInfoRepository.saveAll(infos); + + final Map> hashBatch = new HashMap<>(); + for (SeatScheduleInfo info : infos) { + String hashKey = RedisKeyProvider.seatStatusKey(info.getSchedule().getId()); + hashBatch + .computeIfAbsent(hashKey, k -> new HashMap<>()) + .put(info.getId().toString(), SeatStatus.AVAILABLE); + } + + RedisCallback pipelineWork = connection -> { + // ZSET 제거 + connection.zRem( + zsetKey.getBytes(), + expiredIds.stream() + .map(String::getBytes) + .toArray(byte[][]::new) + ); + + // 해시 업데이트 + for (Map.Entry> e : hashBatch.entrySet()) { + byte[] hashKey = redisTemplate.getStringSerializer().serialize(e.getKey()); + Map serialized = new HashMap<>(); + e.getValue().forEach((field, value) -> + serialized.put( + redisTemplate.getStringSerializer().serialize(field.toString()), + redisTemplate.getStringSerializer().serialize(value.toString()) + ) + ); + connection.hMSet(hashKey, serialized); + } + + return null; + }; + + redisTemplate.executePipelined(pipelineWork); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index 4e97908..ab73ffd 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; @@ -59,6 +60,7 @@ public RedisTemplate redisJsonTemplate(RedisConnectionFactory co } @Bean + @Primary RedisTemplate redisStringTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); From 83475ee1f6a43bd9ff1084548a9c5d792f0449b2 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sun, 25 May 2025 07:56:54 +0900 Subject: [PATCH 44/53] =?UTF-8?q?feat=20:=20=EC=98=88=EB=A7=A4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C,=20=EA=B2=B0=EC=A0=9C=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=97=90=20=EC=A2=8C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 4 ++- .../service/ReservationService.java | 11 +++++--- .../service/SeatScheduleInfoService.java | 26 +++++++++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) 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 adfba62..dfb99e6 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 @@ -28,7 +28,9 @@ public void savePayment(PaymentConfirmRequestDto dto) { paymentRepository.save(payment); - //seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + seatScheduleInfoService.updateSeatScheduleInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + seatScheduleInfoService.applySeatLockTTL(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } 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 603479d..442f7aa 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 @@ -13,6 +13,7 @@ import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -28,14 +29,13 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; - private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final SeatScheduleInfoService seatScheduleInfoService; @Transactional 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)); + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoService.findById(seatScheduleInfoId); Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); @@ -68,8 +68,11 @@ public void delete(String username, Long reservationId) { throw new CustomException(ErrorCode.INVALID_RESERVATION_USER); } + Long seatScheduleInfoId = reservation.getSeatScheduleInfo().getId(); reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); + + seatScheduleInfoService.updateSeatScheduleInfoStatus(seatScheduleInfoId, SeatStatus.AVAILABLE); + seatScheduleInfoService.applySeatLockTTL(seatScheduleInfoId, SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 21d3a13..1c5eda1 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -3,6 +3,8 @@ import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +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.example.siljeun.domain.seat.entity.Seat; @@ -35,7 +37,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { //예외 상황 처리 SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ @@ -65,7 +67,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { redisTemplate.opsForValue().set(redisLockKey, userId.toString()); //Redis 상태 변경 - updateSeatSchedulerInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); //TTL 적용 applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); @@ -78,7 +80,7 @@ public Map getSeatStatusMap(Long scheduleId) { List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); if(seatScheduleInfos.isEmpty()){ - throw new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다."); + throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); } List fieldKeys = seatScheduleInfos.stream() @@ -118,14 +120,23 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } + @Transactional + public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + } - public void updateSeatSchedulerInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); } - private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ + public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ String member = seatScheduleInfoId.toString(); String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); @@ -154,4 +165,9 @@ private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ break; } } + + public SeatScheduleInfo findById(Long seatScheduleInfoId){ + return seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + } } From d9c6e55bcc723febad46771b9b830d97795924fc Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sun, 25 May 2025 08:08:16 +0900 Subject: [PATCH 45/53] =?UTF-8?q?refactor(Seat,=20SeatScheduleInfo)=20:=20?= =?UTF-8?q?CustomExeption=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/exception/ErrorCode.java | 8 ++++++++ .../siljeun/domain/seat/service/SeatService.java | 6 ++++-- .../service/SeatScheduleInfoService.java | 10 +++++----- 3 files changed, 17 insertions(+), 7 deletions(-) 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 6c0b852..90cf25a 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 @@ -18,6 +18,14 @@ public enum ErrorCode { // seatScheduleInfo NOT_FOUNT_SEAT_SCHEDULE_INFO(404, "해당 공연에 대한 좌석 정보가 존재하지 않습니다."), + ALREADY_SELECTED_SEAT(409, "이미 선점된 좌석입니다."), + SEAT_LIMIT_ONE_PER_USER(409, "1인당 1개의 좌석만 예약 가능합니다."), + + // venue + NOT_FOUND_VENUE(404, "해당 공연장을 찾을 수 없습니다."), + + // seat + SEAT_CAPACITY_EXCEEDED(400, "좌석 수가 공연장 수용 인원(capacity)을 초과했습니다."), // jwt UNAUTHORIZED(401, "토큰이 유효하지 않습니다."), diff --git a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java index 1a3e3f8..75a8ff0 100644 --- a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java +++ b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java @@ -1,6 +1,8 @@ package org.example.siljeun.domain.seat.service; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.seat.dto.request.SeatCreateRequest; import org.example.siljeun.domain.seat.entity.Seat; import org.example.siljeun.domain.venue.entity.Venue; @@ -23,10 +25,10 @@ public class SeatService { public void createSeats(Long venueId, List seatCreateRequests){ Venue venue = venueRepository.findById(venueId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 공연장을 찾을 수 없습니다.")); //Throw 예외 설정 필요 + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_VENUE)); if (seatCreateRequests.size() > venue.getSeatCapacity()) { - throw new IllegalArgumentException("좌석 수가 공연장 수용 인원(capacity)을 초과했습니다."); + throw new CustomException(ErrorCode.SEAT_CAPACITY_EXCEEDED); } //공연장 ID, 구역, 열, 번호를 바탕으로 고유하도록 설정하였으나 //좌석 정보가 중복되는 경우를 다루지 않아 추후 리팩토링이 필요함 diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 1c5eda1..e8820e6 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -41,16 +41,16 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); + throw new CustomException(ErrorCode.NOT_TICKETING_TIME); } if (!seatScheduleInfo.isAvailable()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); + throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); } String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); + throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); } //DB 상태 변경 @@ -76,7 +76,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { public Map getSeatStatusMap(Long scheduleId) { Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); if(seatScheduleInfos.isEmpty()){ @@ -107,7 +107,7 @@ public Map getSeatStatusMap(Long scheduleId) { public void forceSeatScheduleInfoInRedis(Long scheduleId){ Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); From c4c3a03484ce3a40f7a3ce71bde42ed4c73e2b0b Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Sun, 25 May 2025 12:36:45 +0900 Subject: [PATCH 46/53] =?UTF-8?q?chore=20:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=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 --- .../SeatScheduleInfoController.java | 0 src/main/resources/application-test.yml | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+) delete mode 100644 src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java create mode 100644 src/main/resources/application-test.yml 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 deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..98e34ff --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,43 @@ +spring: + application: + name: siljeun + data: + redis: + host: localhost + port: 6379 + + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: root + password: 0000 + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + +h2: + console: + enabled: true + path: /h2-console + +kakao: + client-id: eaee0e144aeb9afef54d5c449448baea # ??? REST API ? + redirect-uri: http://localhost:8080/oauth/kakao/callback + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + +server: + port: 8080 + +toss: + client-key: test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + +jwt: + secret: + key: OUpWdGF6SnY2SVk0aXYxa1FHM3dOdTk2VFVDMVhLNGM= \ No newline at end of file From 616fc3eda8d4d635a181aee32549e78f416d27a8 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 03:57:47 +0900 Subject: [PATCH 47/53] =?UTF-8?q?fix=20:=20WebSocketTest=20=EC=98=A4?= =?UTF-8?q?=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 --- .../example/siljeun/global/config/SecurityConfig.java | 10 +++++----- .../example/siljeun/global/queueing/WebSocketTest.java | 8 +++----- 2 files changed, 8 insertions(+), 10 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 70a80e3..62945a8 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -35,11 +35,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/checkout.html", "/payments", "/success.html").permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .successHandler(customOAuth2SuccessHandler) - .defaultSuccessUrl("/auth/oauth2/success") - .failureUrl("/auth/oauth2/failure") - ) +// .oauth2Login(oauth2 -> oauth2 +// .successHandler(customOAuth2SuccessHandler) +// .defaultSuccessUrl("/auth/oauth2/success") +// .failureUrl("/auth/oauth2/failure") +// ) // .formLogin(form -> form // .loginPage("/login")) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), diff --git a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java index e85fc3c..9fa1167 100644 --- a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java +++ b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java @@ -23,13 +23,12 @@ 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") +//@ActiveProfiles("test") public class WebSocketTest { @LocalServerPort @@ -71,11 +70,10 @@ void socket_connection_test() throws Exception { stompClient.setMessageConverter(new MappingJackson2MessageConverter()); URI uri = new URI( - "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId() + "&token=" - + validToken); + "ws://localhost:" + port + "/ws?scheduleId=" + savedSchedule.getId()); WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); - webSocketHttpHeaders.add("Authorization", JwtUtil.BEARER_PREFIX + validToken); + webSocketHttpHeaders.add("Authorization", validToken); StompHeaders stompHeaders = new StompHeaders(); From 0d1a79c6e955ec1d5a7fc751807a82d1dc6743ea Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 04:35:24 +0900 Subject: [PATCH 48/53] =?UTF-8?q?test=20:=20addWaitingQueue()=201001?= =?UTF-8?q?=EB=AA=85=20=EC=9C=A0=EC=A0=80=20=EB=93=B1=EB=A1=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WaitingQueueService.java | 3 +- .../service/WaitingQueueServiceTest.java | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java 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 b1485dd..acac263 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 @@ -110,11 +110,12 @@ public void sendWaitingNumber(String key, String username, Long scheduleId) { String destination = "/topic/queue/" + scheduleId + "/" + username; MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank, true); - messagingTemplate.convertAndSend(destination, response); addSelectingQueue(scheduleId, username); deleteWaitingUser(scheduleId, username); + messagingTemplate.convertAndSend(destination, response); + return; } diff --git a/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java new file mode 100644 index 0000000..536d2b7 --- /dev/null +++ b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java @@ -0,0 +1,45 @@ +package org.example.siljeun.domain.reservation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +class WaitingQueueServiceTest { + + private final WaitingQueueService waitingQueueService; + private final StringRedisTemplate redisTemplate; + + @Autowired + WaitingQueueServiceTest(WaitingQueueService waitingQueueService, + StringRedisTemplate redisTemplate) { + this.waitingQueueService = waitingQueueService; + this.redisTemplate = redisTemplate; + } + + @Test + @Transactional + void addWaitingQueue() { + // given + ZSetOperations zSet = redisTemplate.opsForZSet(); + + for (int i = 1; i <= 1001; i++) { + waitingQueueService.addWaitingQueue(1L, "user" + i); + } + + // when + Long score = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1001"); + + // then + assertThat(score).isEqualTo(0); + } + + @Test + void deleteWaitingUser() { + } +} \ No newline at end of file From 6d18317e7a640d618455ce36efdd6afdc005ee74 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 04:47:59 +0900 Subject: [PATCH 49/53] =?UTF-8?q?test=20:=20deleteSelectingQueue()=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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 --- .../service/WaitingQueueServiceTest.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java index 536d2b7..53bd68c 100644 --- a/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java +++ b/src/test/java/org/example/siljeun/domain/reservation/service/WaitingQueueServiceTest.java @@ -24,7 +24,7 @@ class WaitingQueueServiceTest { @Test @Transactional - void addWaitingQueue() { + void _1001명_대기시_1001번째_유저가_대기0순위() { // given ZSetOperations zSet = redisTemplate.opsForZSet(); @@ -33,13 +33,31 @@ void addWaitingQueue() { } // when - Long score = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1001"); + Long rank = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1001"); // then - assertThat(score).isEqualTo(0); + assertThat(rank).isEqualTo(0); } @Test - void deleteWaitingUser() { + @Transactional + void selectingQueue에서_1명나가면_1002번이_대기0순위() { + // given + ZSetOperations zSet = redisTemplate.opsForZSet(); + waitingQueueService.addWaitingQueue(1L, "user1002"); + + // when + waitingQueueService.deleteSelectingUser(1L, "user1000"); + + Long rank1001AtWaiting = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, + "user1001"); + Long rank1001AtSelecting = zSet.rank(waitingQueueService.prefixKeyForSelecingQueue + 1L, + "user1001"); + Long rank1002 = zSet.rank(waitingQueueService.prefixKeyForWaitingQueue + 1L, "user1002"); + + // then + assertThat(rank1001AtWaiting).isEqualTo(null); + assertThat(rank1001AtSelecting).isEqualTo(999); + assertThat(rank1002).isEqualTo(0); } } \ No newline at end of file From fdded9eafc546dd2ea607405f2d0873de0de73b2 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 10:46:48 +0900 Subject: [PATCH 50/53] =?UTF-8?q?feat=20:=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EA=B3=BC=20=EC=A2=8C=EC=84=9D=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/exception/ErrorCode.java | 3 +- .../scheduler/CheckExpiredScheduler.java | 2 +- .../service/WaitingQueueService.java | 2 +- .../service/SeatScheduleInfoService.java | 265 ++++++++++-------- 4 files changed, 146 insertions(+), 126 deletions(-) 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 90cf25a..6dc9551 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 @@ -32,7 +32,8 @@ public enum ErrorCode { // queue QUEUE_INSERT_FAIL(500, "대기열 등록을 실패했습니다."), - NOT_TICKETING_TIME(400, "예매 가능 시간이 아닙니다."); + NOT_TICKETING_TIME(400, "예매 가능 시간이 아닙니다."), + PRECONDITION_REQUIRED(400, "선행 조건이 수행되지 않았습니다."); private HttpStatus code; private String message; 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 index e8d3039..d754add 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java @@ -28,7 +28,7 @@ public class CheckExpiredScheduler { private final Set keys = new HashSet<>(); // 1시간마다 티켓팅 기간인 schedule을 keys에 저장 - // Todo : 동시성 문제는 clear() 떄문에 발생(redis 자체는 동시성 문제 없음) + // 동시성 문제는 clear() 떄문에 발생(redis 자체는 동시성 문제 없음) @Scheduled(cron = "0 0 * * * *") public void checkOpenedSchedule() { 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 acac263..d3a4411 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 @@ -126,7 +126,7 @@ public void sendWaitingNumber(String key, String username, Long scheduleId) { } // 대기중인 모든 사용자에게 랭킹 및 대기번호 전송 - // Todo : redis pubsub -> disconnectListener에서 publish + // redis pubsub -> disconnectListener에서 publish public void sendAllWaitingNumber(Long scheduleId) { String key = prefixKeyForWaitingQueue + scheduleId; diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index e8820e6..e32d737 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -1,173 +1,192 @@ package org.example.siljeun.domain.seatscheduleinfo.service; -import jakarta.persistence.EntityNotFoundException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.reservation.exception.CustomException; import org.example.siljeun.domain.reservation.exception.ErrorCode; +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.seat.entity.Seat; -import org.example.siljeun.domain.seat.repository.SeatRepository; -import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; -import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.domain.user.entity.User; +import org.example.siljeun.domain.user.repository.UserRepository; import org.example.siljeun.global.lock.DistributedLock; import org.example.siljeun.global.util.RedisKeyProvider; 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.time.LocalDateTime; -import java.util.*; @Slf4j @Service @RequiredArgsConstructor public class SeatScheduleInfoService { - private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final ScheduleRepository scheduleRepository; - private final RedisTemplate redisTemplate; - - @DistributedLock(key = "'seat:' + #seatScheduleInfoId") - public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { - //예외 상황 처리 - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final ScheduleRepository scheduleRepository; + private final RedisTemplate redisTemplate; + private final WaitingQueueService waitingQueueService; + private final UserRepository userRepository; - Schedule schedule = seatScheduleInfo.getSchedule(); - if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - throw new CustomException(ErrorCode.NOT_TICKETING_TIME); - } + @DistributedLock(key = "'seat:' + #seatScheduleInfoId") + public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { - if (!seatScheduleInfo.isAvailable()) { - throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); - } + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); - String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); - if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { - throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); - } - - //DB 상태 변경 - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); + //대기열을 거쳐서 요청했는지 검증 (정상적인 요청인지 검증) + boolean hasPassed = waitingQueueService.hasPassedWaitingQueue(scheduleId, user.getUsername()); + if (!hasPassed) { + throw new CustomException(ErrorCode.PRECONDITION_REQUIRED); + } - //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) - redisTemplate.opsForValue() - .set(redisSelectedKey, seatScheduleInfoId.toString()); - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); + //예외 상황 처리 + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). + orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); - //TTL 관리를 위한 키 생성 - String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); - redisTemplate.opsForValue().set(redisLockKey, userId.toString()); + Schedule schedule = seatScheduleInfo.getSchedule(); + if (schedule.getTicketingStartTime().isAfter(LocalDateTime.now())) { + throw new CustomException(ErrorCode.NOT_TICKETING_TIME); + } - //Redis 상태 변경 - updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + if (!seatScheduleInfo.isAvailable()) { + throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); + } - //TTL 적용 - applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); + String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); + if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { + throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); } - public Map getSeatStatusMap(Long scheduleId) { + //DB 상태 변경 + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) + redisTemplate.opsForValue() + .set(redisSelectedKey, seatScheduleInfoId.toString()); + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - if(seatScheduleInfos.isEmpty()){ - throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); - } + //TTL 관리를 위한 키 생성 + String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + redisTemplate.opsForValue().set(redisLockKey, userId.toString()); - List fieldKeys = seatScheduleInfos.stream() - .map(info -> info.getId().toString()) - .toList(); + //Redis 상태 변경 + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); + //TTL 적용 + applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); - Map seatStatusMap = new HashMap<>(); - for (int i = 0; i < seatScheduleInfos.size(); i++) { - SeatScheduleInfo info = seatScheduleInfos.get(i); - Object redisStatusObj = redisStatuses.get(i); + //좌석 선택중인 유저 queue에서 데이터 삭제 + waitingQueueService.addSelectingQueue(scheduleId, user.getUsername()); + } - String status = redisStatusObj != null - ? redisStatusObj.toString() - : seatScheduleInfos.get(i).getStatus().name(); + public Map getSeatStatusMap(Long scheduleId) { - seatStatusMap.put("seatScheduleInfo-" + info.getId().toString(), status); - } + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); - return seatStatusMap; + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule( + schedule); + if (seatScheduleInfos.isEmpty()) { + throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); } - public void forceSeatScheduleInfoInRedis(Long scheduleId){ - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + List fieldKeys = seatScheduleInfos.stream() + .map(info -> info.getId().toString()) + .toList(); - List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + List redisStatuses = redisTemplate.opsForHash() + .multiGet(redisKey, new ArrayList<>(fieldKeys)); - String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); - Map seatStatusMap = new HashMap<>(); + Map seatStatusMap = new HashMap<>(); + for (int i = 0; i < seatScheduleInfos.size(); i++) { + SeatScheduleInfo info = seatScheduleInfos.get(i); + Object redisStatusObj = redisStatuses.get(i); - for (SeatScheduleInfo seat : seatInfos) { - seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); - } + String status = redisStatusObj != null + ? redisStatusObj.toString() + : seatScheduleInfos.get(i).getStatus().name(); - redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); - } - @Transactional - public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); - - Long scheduleId = seatScheduleInfo.getSchedule().getId(); - updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + seatStatusMap.put("seatScheduleInfo-" + info.getId().toString(), status); } - public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - String fieldKey = seatScheduleInfoId.toString(); - redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); - } + return seatStatusMap; + } + + public void forceSeatScheduleInfoInRedis(Long scheduleId) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); + + List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ - String member = seatScheduleInfoId.toString(); - - String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); - String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); - String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); - - Duration ttl = null; - long nowMillis = System.currentTimeMillis(); - - redisTemplate.opsForZSet().remove(zsetSelectedKey, member); - redisTemplate.opsForZSet().remove(zsetHoldKey, member); - - switch(seatStatus){ - case SELECTED: - ttl = Duration.ofMinutes(5); - redisTemplate.expire(seatLockkey, ttl); - redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); - break; - case HOLD: - ttl = Duration.ofMinutes(60); - redisTemplate.expire(seatLockkey, ttl); - redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis+ttl.toMillis()); - break; - default: - redisTemplate.persist(seatLockkey); - break; - } + String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); + Map seatStatusMap = new HashMap<>(); + + for (SeatScheduleInfo seat : seatInfos) { + seatStatusMap.put(seat.getId().toString(), seat.getStatus().name()); } - public SeatScheduleInfo findById(Long seatScheduleInfoId){ - return seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); + } + + @Transactional + public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus) { + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + } + + public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, + SeatStatus seatStatus) { + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + String fieldKey = seatScheduleInfoId.toString(); + redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); + } + + public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus) { + String member = seatScheduleInfoId.toString(); + + String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); + String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); + + Duration ttl = null; + long nowMillis = System.currentTimeMillis(); + + redisTemplate.opsForZSet().remove(zsetSelectedKey, member); + redisTemplate.opsForZSet().remove(zsetHoldKey, member); + + switch (seatStatus) { + case SELECTED: + ttl = Duration.ofMinutes(5); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis + ttl.toMillis()); + break; + case HOLD: + ttl = Duration.ofMinutes(60); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis + ttl.toMillis()); + break; + default: + redisTemplate.persist(seatLockkey); + break; } + } + + public SeatScheduleInfo findById(Long seatScheduleInfoId) { + return seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + } } From f59ad772c707d75571fcf0b78d74bc74fc1ed579 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 11:14:15 +0900 Subject: [PATCH 51/53] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=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 --- .../siljeun/global/config/SecurityConfig.java | 10 ++--- src/main/resources/application-test.yml | 43 ------------------- 2 files changed, 5 insertions(+), 48 deletions(-) delete mode 100644 src/main/resources/application-test.yml 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 62945a8..70a80e3 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -35,11 +35,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/checkout.html", "/payments", "/success.html").permitAll() .anyRequest().authenticated() ) -// .oauth2Login(oauth2 -> oauth2 -// .successHandler(customOAuth2SuccessHandler) -// .defaultSuccessUrl("/auth/oauth2/success") -// .failureUrl("/auth/oauth2/failure") -// ) + .oauth2Login(oauth2 -> oauth2 + .successHandler(customOAuth2SuccessHandler) + .defaultSuccessUrl("/auth/oauth2/success") + .failureUrl("/auth/oauth2/failure") + ) // .formLogin(form -> form // .loginPage("/login")) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml deleted file mode 100644 index 98e34ff..0000000 --- a/src/main/resources/application-test.yml +++ /dev/null @@ -1,43 +0,0 @@ -spring: - application: - name: siljeun - data: - redis: - host: localhost - port: 6379 - - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:testDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - username: root - password: 0000 - - jpa: - hibernate: - ddl-auto: create-drop - show-sql: true - properties: - hibernate: - format_sql: true - -h2: - console: - enabled: true - path: /h2-console - -kakao: - client-id: eaee0e144aeb9afef54d5c449448baea # ??? REST API ? - redirect-uri: http://localhost:8080/oauth/kakao/callback - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - -server: - port: 8080 - -toss: - client-key: test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm - secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 - -jwt: - secret: - key: OUpWdGF6SnY2SVk0aXYxa1FHM3dOdTk2VFVDMVhLNGM= \ No newline at end of file From 87e68a053a23394374220d2e5cccc37562de431f Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 11:20:07 +0900 Subject: [PATCH 52/53] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=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 --- .../domain/reservation/scheduler/CheckExpiredScheduler.java | 6 ------ .../domain/reservation/service/WaitingQueueService.java | 1 - 2 files changed, 7 deletions(-) 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 index d754add..108b724 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java +++ b/src/main/java/org/example/siljeun/domain/reservation/scheduler/CheckExpiredScheduler.java @@ -27,15 +27,9 @@ public class CheckExpiredScheduler { private final Set keys = new HashSet<>(); - // 1시간마다 티켓팅 기간인 schedule을 keys에 저장 - // 동시성 문제는 clear() 떄문에 발생(redis 자체는 동시성 문제 없음) @Scheduled(cron = "0 0 * * * *") public void checkOpenedSchedule() { - keys.clear(); // checkExpired()랑 동시에 돌다가 얘가 좀 더 빨리 돌면 clear()돼서 비어있는걸 checkExpired 하게됨 -> 동시성 문제 - - // 데이터 조회할 때는 부하 방지를 위해 키가 일치하는 경우만 조회 (like 검색 실무에선 못씀.) - List openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore( LocalDateTime.now(), LocalDateTime.now()).stream() 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 d3a4411..5ef1f0c 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 @@ -126,7 +126,6 @@ public void sendWaitingNumber(String key, String username, Long scheduleId) { } // 대기중인 모든 사용자에게 랭킹 및 대기번호 전송 - // redis pubsub -> disconnectListener에서 publish public void sendAllWaitingNumber(Long scheduleId) { String key = prefixKeyForWaitingQueue + scheduleId; From 195ad54f40fc55ce992558e3f9ee8478404abe90 Mon Sep 17 00:00:00 2001 From: kmchaejin Date: Mon, 26 May 2025 12:54:52 +0900 Subject: [PATCH 53/53] =?UTF-8?q?test=20:=20=EB=8C=80=EA=B8=B0=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9D=91=EB=8B=B5=20=EC=84=B1=EA=B3=B5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/queueing/WebSocketTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java index 9fa1167..4ef6d98 100644 --- a/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java +++ b/src/test/java/org/example/siljeun/global/queueing/WebSocketTest.java @@ -1,13 +1,21 @@ package org.example.siljeun.global.queueing; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Type; import java.net.URI; +import java.net.URISyntaxException; import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; 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.reservation.dto.response.MyQueueInfoResponse; +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.venue.entity.Venue; @@ -20,9 +28,11 @@ 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.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; @@ -43,6 +53,9 @@ public class WebSocketTest { @Autowired private ConcertRepository concertRepository; + @Autowired + private WaitingQueueService waitingQueueService; + @Autowired private JwtUtil jwtUtil; @@ -84,4 +97,54 @@ void socket_connection_test() throws Exception { assertTrue(session.isConnected()); } + + @Test + @Transactional + void 대기번호_응답_성공() + throws URISyntaxException, ExecutionException, InterruptedException, TimeoutException { + // given + // 소켓 연결 + WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + URI uri = new URI( + "ws://localhost:" + port + "/ws?scheduleId=1"); + WebSocketHttpHeaders webSocketHttpHeaders = new WebSocketHttpHeaders(); + webSocketHttpHeaders.add("Authorization", validToken); + + StompHeaders stompHeaders = new StompHeaders(); + StompSession session = stompClient.connectAsync(uri, webSocketHttpHeaders, stompHeaders, + new StompSessionHandlerAdapter() { + } + ).get(5, TimeUnit.SECONDS); + + // 메시지 수신 대기용 변수 + CompletableFuture completableFuture = new CompletableFuture<>(); + + String destination = "/topic/queue/1/user1002"; + session.subscribe(destination, new StompFrameHandler() { + + @Override + public Type getPayloadType(StompHeaders headers) { + return MyQueueInfoResponse.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + completableFuture.complete((MyQueueInfoResponse) payload); + } + }); + + // key 설정 + String key = waitingQueueService.prefixKeyForWaitingQueue + 1L; + + // when + waitingQueueService.sendWaitingNumber(key, "user1002", 1L); + + // then + MyQueueInfoResponse response = completableFuture.get(5, TimeUnit.SECONDS); // 메시지 수신 대기 + assertThat(response.username()).isEqualTo("user1002"); + assertThat(response.scheduleId()).isEqualTo(1); + assertThat(response.rank()).isGreaterThanOrEqualTo(1); + } }