From 173e415ab887223e8949416c2c4f9e05ec7d55dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=88=98=EC=A7=84?= <71487608+ssssujini99@users.noreply.github.com> Date: Sat, 13 Jan 2024 17:11:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]:=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=98=88=EB=A7=A4=20?= =?UTF-8?q?=ED=8B=B0=EC=BC=93=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [chore]: 페이징을 위한 의존성 추가 * [feat]: 예매 조회 controller 추가 * [test]: 예매 조회 인수테스트 추가 * [feat]: 예매 조회 application 추가 * [feat]: 예매 조회 repository, adaptor 추가 * [test]: 예매 조회 jpaRepository 쿼리 테스트 추가 * [refactor]: 예약 도메인 리팩토링 및 도메인 내장함수 추가 * [feat]: 예매 조회 응답 Response 추가 * [refactor]: 예매, 티켓 도메인 리팩토링 및 내장함수 추가 * [feat]: Response 매퍼 추가 * [refactor]: 리팩토링 적용 * [refactor]: 테스트 리팩토링 * [refactor]: 리팩토링 --- api/build.gradle | 1 + .../hooon/booking/BookingApiController.java | 39 ++++++++ .../booking/BookingApiControllerTest.java | 98 +++++++++++++++++++ .../booking/application/BookingService.java | 18 +++- .../hooon/booking/domain/entity/Booking.java | 49 +++++++++- .../hooon/booking/domain/entity/Ticket.java | 15 +++ .../domain/repository/BookingRepository.java | 9 ++ .../dev/hooon/booking/dto/BookingMapper.java | 21 ++++ .../dto/response/BookingListResponse.java | 8 ++ .../booking/dto/response/BookingResponse.java | 15 +++ .../dto/response/ShowInfoResponse.java | 16 +++ .../adaptor/BookingRepositoryAdaptor.java | 17 ++++ .../repository/BookingJpaRepository.java | 11 +++ .../resources/sql/user_bookings_find.sql | 76 ++++++++++++++ 14 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 api/src/main/java/dev/hooon/booking/BookingApiController.java create mode 100644 api/src/test/java/dev/hooon/booking/BookingApiControllerTest.java create mode 100644 core/src/main/java/dev/hooon/booking/dto/response/BookingListResponse.java create mode 100644 core/src/main/java/dev/hooon/booking/dto/response/BookingResponse.java create mode 100644 core/src/main/java/dev/hooon/booking/dto/response/ShowInfoResponse.java create mode 100644 core/src/testFixtures/resources/sql/user_bookings_find.sql diff --git a/api/build.gradle b/api/build.gradle index 63e3dff8..edcb99fb 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,6 +1,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework:spring-tx:6.1.1' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' diff --git a/api/src/main/java/dev/hooon/booking/BookingApiController.java b/api/src/main/java/dev/hooon/booking/BookingApiController.java new file mode 100644 index 00000000..3fbba769 --- /dev/null +++ b/api/src/main/java/dev/hooon/booking/BookingApiController.java @@ -0,0 +1,39 @@ +package dev.hooon.booking; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import dev.hooon.auth.jwt.JwtAuthorization; +import dev.hooon.booking.application.BookingService; +import dev.hooon.booking.dto.response.BookingListResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class BookingApiController { + + private final BookingService bookingService; + + @GetMapping("/api/users/booking") + @Operation(summary = "예매 조회 API", description = "예매를 조회한다") + @ApiResponse(responseCode = "200", useReturnTypeSchema = true) + public ResponseEntity getBookings( + @Parameter(hidden = true) @JwtAuthorization Long userId, + @RequestParam(name = "duration") int duration, + @PageableDefault(size = 10) Pageable pageable + ) { + BookingListResponse bookingListResponse = bookingService.getBookings( + userId, + duration, + pageable + ); + return ResponseEntity.ok(bookingListResponse); + } +} diff --git a/api/src/test/java/dev/hooon/booking/BookingApiControllerTest.java b/api/src/test/java/dev/hooon/booking/BookingApiControllerTest.java new file mode 100644 index 00000000..782ccd92 --- /dev/null +++ b/api/src/test/java/dev/hooon/booking/BookingApiControllerTest.java @@ -0,0 +1,98 @@ +package dev.hooon.booking; + +import static org.springframework.http.HttpHeaders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import dev.hooon.common.support.ApiTestSupport; +import dev.hooon.user.domain.entity.User; + +@DisplayName("[BookingApiController API 테스트]") +@Sql("/sql/user_bookings_find.sql") +class BookingApiControllerTest extends ApiTestSupport { + + @Autowired + private MockMvc mockMvc; + + @DisplayName("사용자는 조회 기간에 따른 예매 정보를 조회할 수 있다 - 1") + @Test + void getBookings_test_1() throws Exception { + + // given + User user = new User(); + ReflectionTestUtils.setField(user, "id", 1L); + + // when + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/users/booking?duration=3600&page=0&size=2") + .header(AUTHORIZATION, accessToken) + ); + + // then + resultActions.andExpectAll( + status().isOk(), + + jsonPath("$.bookingList.length()").value(2), + + jsonPath("$.bookingList[0].bookingId").isNumber(), + jsonPath("$.bookingList[0].bookingDate").isString(), + jsonPath("$.bookingList[0].showInfo.showName").isString(), + jsonPath("$.bookingList[0].showInfo.showDate").isString(), + jsonPath("$.bookingList[0].showInfo.showRound").isNumber(), + jsonPath("$.bookingList[0].showInfo.showRoundStartTime").isString(), + jsonPath("$.bookingList[0].ticketNumber").isNumber(), + jsonPath("$.bookingList[0].currentState").isString(), + + jsonPath("$.bookingList[1].bookingId").isNumber(), + jsonPath("$.bookingList[1].bookingDate").isString(), + jsonPath("$.bookingList[1].showInfo.showName").isString(), + jsonPath("$.bookingList[1].showInfo.showDate").isString(), + jsonPath("$.bookingList[1].showInfo.showRound").isNumber(), + jsonPath("$.bookingList[1].showInfo.showRoundStartTime").isString(), + jsonPath("$.bookingList[1].ticketNumber").isNumber(), + jsonPath("$.bookingList[1].currentState").isString() + ); + } + + @DisplayName("사용자는 조회 기간에 따른 예매 정보를 조회할 수 있다 - 2") + @Test + void getBookings_test_2() throws Exception { + + // given + User user = new User(); + ReflectionTestUtils.setField(user, "id", 1L); + + // when + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/users/booking?duration=30&page=0&size=2") + .header(AUTHORIZATION, accessToken) + ); + + // then + resultActions.andExpectAll( + status().isOk(), + + jsonPath("$.bookingList.length()").value(1), + + jsonPath("$.bookingList[0].bookingId").isNumber(), + jsonPath("$.bookingList[0].bookingDate").isString(), + jsonPath("$.bookingList[0].showInfo.showName").isString(), + jsonPath("$.bookingList[0].showInfo.showDate").isString(), + jsonPath("$.bookingList[0].showInfo.showRound").isNumber(), + jsonPath("$.bookingList[0].showInfo.showRoundStartTime").isString(), + jsonPath("$.bookingList[0].ticketNumber").isNumber(), + jsonPath("$.bookingList[0].currentState").isString() + + ); + } +} diff --git a/core/src/main/java/dev/hooon/booking/application/BookingService.java b/core/src/main/java/dev/hooon/booking/application/BookingService.java index cbe07674..ff05e9df 100644 --- a/core/src/main/java/dev/hooon/booking/application/BookingService.java +++ b/core/src/main/java/dev/hooon/booking/application/BookingService.java @@ -2,7 +2,12 @@ import static dev.hooon.booking.exception.BookingErrorCode.*; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import dev.hooon.booking.domain.entity.Booking; import dev.hooon.booking.domain.entity.BookingStatus; @@ -10,9 +15,9 @@ import dev.hooon.booking.domain.repository.BookingRepository; import dev.hooon.booking.dto.BookingMapper; import dev.hooon.booking.dto.response.BookingCancelResponse; +import dev.hooon.booking.dto.response.BookingListResponse; import dev.hooon.common.exception.NotFoundException; import dev.hooon.common.exception.ValidationException; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @Service @@ -65,4 +70,15 @@ public Booking getById(Long id) { ); } + @Transactional(readOnly = true) + public BookingListResponse getBookings( + Long userId, + int days, + Pageable pageable + ) { + LocalDateTime currentDateTime = LocalDateTime.now(); + LocalDateTime createdDateTime = currentDateTime.minusDays(days); + List bookingList = bookingRepository.findByUserIdAndDays(userId, createdDateTime, pageable); + return BookingMapper.toBookingListResponse(bookingList); + } } diff --git a/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java b/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java index 1b6f2a6d..a007a141 100644 --- a/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java +++ b/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java @@ -7,6 +7,9 @@ import static jakarta.persistence.GenerationType.*; import static lombok.AccessLevel.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.ArrayList; import java.util.List; @@ -46,12 +49,12 @@ public class Booking extends TimeBaseEntity { @JoinColumn(name = "booking_show_id", nullable = false, foreignKey = @ForeignKey(value = NO_CONSTRAINT)) private Show show; - @Enumerated(STRING) - @Column(name = "booking_status", nullable = false) - private BookingStatus bookingStatus; + @Enumerated(STRING) + @Column(name = "booking_status", nullable = false) + private BookingStatus bookingStatus; - @Column(name = "booking_ticket_count", nullable = false) - private int ticketCount = 0; + @Column(name = "booking_ticket_count", nullable = false) + private int ticketCount = 0; @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) private final List tickets = new ArrayList<>(); @@ -68,6 +71,13 @@ private Booking(User user, Show show) { this.bookingStatus = BOOKED; } + private Booking(User user, Show show, LocalDateTime localDateTime) { + this.user = user; + this.show = show; + this.bookingStatus = BOOKED; + this.createdAt = localDateTime; + } + public static Booking of( User user, Show show @@ -75,6 +85,14 @@ public static Booking of( return new Booking(user, show); } + public static Booking of( + User user, + Show show, + LocalDateTime localDateTime + ) { + return new Booking(user, show, localDateTime); + } + public void markBookingStatusAsCanceled() { this.bookingStatus = CANCELED; } @@ -82,4 +100,25 @@ public void markBookingStatusAsCanceled() { public long getUserId() { return this.user.getId(); } + + public String getShowName() { + return this.getShow().getName(); + } + + public LocalDate getShowDate() { + return getFirstTicket().getShowDate(); + } + + public int getRound() { + return getFirstTicket().getRound(); + } + + public LocalTime getStartTime() { + return getFirstTicket().getStartTime(); + } + + public Ticket getFirstTicket() { + return this.getTickets().get(0); + } + } diff --git a/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java b/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java index 6fc716ad..0e077f36 100644 --- a/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java +++ b/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java @@ -5,6 +5,9 @@ import static jakarta.persistence.GenerationType.*; import static lombok.AccessLevel.*; +import java.time.LocalDate; +import java.time.LocalTime; + import dev.hooon.common.entity.TimeBaseEntity; import dev.hooon.show.domain.entity.seat.Seat; import jakarta.persistence.Column; @@ -54,4 +57,16 @@ public void setBooking(Booking booking) { public void markSeatStatusAsCanceled() { this.seat.markSeatStatusAsCanceled(); } + + public LocalDate getShowDate() { + return this.getSeat().getShowDate(); + } + + public int getRound() { + return this.getSeat().getRound(); + } + + public LocalTime getStartTime() { + return this.getSeat().getStartTime(); + } } diff --git a/core/src/main/java/dev/hooon/booking/domain/repository/BookingRepository.java b/core/src/main/java/dev/hooon/booking/domain/repository/BookingRepository.java index abf1078d..64229c0b 100644 --- a/core/src/main/java/dev/hooon/booking/domain/repository/BookingRepository.java +++ b/core/src/main/java/dev/hooon/booking/domain/repository/BookingRepository.java @@ -1,8 +1,11 @@ package dev.hooon.booking.domain.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; + import dev.hooon.booking.domain.entity.Booking; public interface BookingRepository { @@ -12,4 +15,10 @@ public interface BookingRepository { List findAll(); Optional findByIdWithTickets(Long id); + + List findByUserIdAndDays( + Long userId, + LocalDateTime createdDateTime, + Pageable pageable + ); } diff --git a/core/src/main/java/dev/hooon/booking/dto/BookingMapper.java b/core/src/main/java/dev/hooon/booking/dto/BookingMapper.java index 49a92852..da5ff28c 100644 --- a/core/src/main/java/dev/hooon/booking/dto/BookingMapper.java +++ b/core/src/main/java/dev/hooon/booking/dto/BookingMapper.java @@ -4,6 +4,9 @@ import dev.hooon.booking.domain.entity.Booking; import dev.hooon.booking.dto.response.BookingCancelResponse; +import dev.hooon.booking.dto.response.BookingListResponse; +import dev.hooon.booking.dto.response.BookingResponse; +import dev.hooon.booking.dto.response.ShowInfoResponse; import dev.hooon.booking.dto.response.TicketBookingResponse; import dev.hooon.booking.dto.response.TicketResponse; import dev.hooon.booking.dto.response.TicketSeatResponse; @@ -45,4 +48,22 @@ public static BookingCancelResponse toBookingCancelResponse(Booking booking) { ticketSeatResponseList ); } + + public static BookingListResponse toBookingListResponse(List bookingList) { + List bookingResponseList = bookingList.stream() + .map(booking -> new BookingResponse( + booking.getId(), + booking.getCreatedAt().toLocalDate(), + new ShowInfoResponse( + booking.getShowName(), + booking.getShowDate(), + booking.getRound(), + booking.getStartTime() + ), + booking.getTicketCount(), + booking.getBookingStatus().toString() + )) + .toList(); + return new BookingListResponse(bookingResponseList); + } } diff --git a/core/src/main/java/dev/hooon/booking/dto/response/BookingListResponse.java b/core/src/main/java/dev/hooon/booking/dto/response/BookingListResponse.java new file mode 100644 index 00000000..e256ba78 --- /dev/null +++ b/core/src/main/java/dev/hooon/booking/dto/response/BookingListResponse.java @@ -0,0 +1,8 @@ +package dev.hooon.booking.dto.response; + +import java.util.List; + +public record BookingListResponse( + List bookingList +) { +} diff --git a/core/src/main/java/dev/hooon/booking/dto/response/BookingResponse.java b/core/src/main/java/dev/hooon/booking/dto/response/BookingResponse.java new file mode 100644 index 00000000..c9a8a954 --- /dev/null +++ b/core/src/main/java/dev/hooon/booking/dto/response/BookingResponse.java @@ -0,0 +1,15 @@ +package dev.hooon.booking.dto.response; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; + +public record BookingResponse( + long bookingId, + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate bookingDate, + ShowInfoResponse showInfo, + int ticketNumber, + String currentState +) { +} diff --git a/core/src/main/java/dev/hooon/booking/dto/response/ShowInfoResponse.java b/core/src/main/java/dev/hooon/booking/dto/response/ShowInfoResponse.java new file mode 100644 index 00000000..c978a2e3 --- /dev/null +++ b/core/src/main/java/dev/hooon/booking/dto/response/ShowInfoResponse.java @@ -0,0 +1,16 @@ +package dev.hooon.booking.dto.response; + +import java.time.LocalDate; +import java.time.LocalTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +public record ShowInfoResponse( + String showName, + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate showDate, + int showRound, + @JsonFormat(pattern = "HH:mm:ss") + LocalTime showRoundStartTime +) { +} diff --git a/core/src/main/java/dev/hooon/booking/infrastructure/adaptor/BookingRepositoryAdaptor.java b/core/src/main/java/dev/hooon/booking/infrastructure/adaptor/BookingRepositoryAdaptor.java index c39a7794..90ce653f 100644 --- a/core/src/main/java/dev/hooon/booking/infrastructure/adaptor/BookingRepositoryAdaptor.java +++ b/core/src/main/java/dev/hooon/booking/infrastructure/adaptor/BookingRepositoryAdaptor.java @@ -1,8 +1,11 @@ package dev.hooon.booking.infrastructure.adaptor; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import dev.hooon.booking.domain.entity.Booking; @@ -30,4 +33,18 @@ public List findAll() { public Optional findByIdWithTickets(Long id) { return bookingJpaRepository.findByIdWithTickets(id); } + + @Override + public List findByUserIdAndDays( + Long userId, + LocalDateTime createdDateTime, + Pageable pageable + ) { + Page bookingPage = bookingJpaRepository.findBookingsByUserIdAndCreatedAtAfter( + userId, + createdDateTime, + pageable + ); + return bookingPage.getContent(); + } } diff --git a/core/src/main/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepository.java b/core/src/main/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepository.java index da3d2600..46b86fae 100644 --- a/core/src/main/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepository.java +++ b/core/src/main/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepository.java @@ -1,7 +1,10 @@ package dev.hooon.booking.infrastructure.repository; +import java.time.LocalDateTime; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,4 +15,12 @@ public interface BookingJpaRepository extends JpaRepository { @Query("SELECT DISTINCT b FROM Booking b LEFT JOIN FETCH b.tickets WHERE b.id = :bookingId") Optional findByIdWithTickets(@Param("bookingId") Long bookingId); + + @Query("SELECT b FROM Booking b WHERE b.user.id = :userId AND b.createdAt >= :createdDateTime") + Page findBookingsByUserIdAndCreatedAtAfter( + @Param("userId") Long userId, + @Param("createdDateTime") LocalDateTime createdDateTime, + Pageable pageable + ); + } diff --git a/core/src/testFixtures/resources/sql/user_bookings_find.sql b/core/src/testFixtures/resources/sql/user_bookings_find.sql new file mode 100644 index 00000000..f38b03ea --- /dev/null +++ b/core/src/testFixtures/resources/sql/user_bookings_find.sql @@ -0,0 +1,76 @@ +INSERT INTO place_table + (place_id, place_name, place_contact_info, place_address, place_url) +VALUES (1, '블루스퀘어 신한카드홀', '1544-1591', '서울특별시 용산구 이태원로 294 블루스퀘어(한남동)', 'http://www.bluesquare.kr/index.asp'); +-- +-- +INSERT INTO show_table +(show_id, show_name, show_category, show_start_date, show_end_date, show_total_minutes, show_intermission_minutes, + show_age_limit, show_total_seats, show_place_id) +VALUES (1, '레미제라블', 'MUSICAL', '2024-01-01', '2024-12-31', 150, 15, '만 8세 이상', 1000, 1); +-- +-- +INSERT INTO show_table +(show_id, show_name, show_category, show_start_date, show_end_date, show_total_minutes, show_intermission_minutes, + show_age_limit, show_total_seats, show_place_id) +VALUES (2, '오페라의 유령', 'MUSICAL', '2024-01-01', '2024-12-31', 150, 15, '만 8세 이상', 1000, 1); +-- +-- +INSERT INTO seat_table +(seat_id, seat_show_id, seat_grade, seat_is_seat, seat_sector, seat_row, seat_col, seat_price, seat_show_date, + seat_show_round, seat_start_time, seat_status) +VALUES (1, 1, 'VIP', true, '1층', 'A', 2, 100000, '2024-01-01', 2, '13:00:00', 'AVAILABLE'); +-- +-- +INSERT INTO seat_table +(seat_id, seat_show_id, seat_grade, seat_is_seat, seat_sector, seat_row, seat_col, seat_price, seat_show_date, + seat_show_round, seat_start_time, seat_status) +VALUES (2, 1, 'VIP', true, '1층', 'A', 3, 100000, '2024-01-01', 2, '13:00:00', 'AVAILABLE'); +-- +-- +INSERT INTO seat_table +(seat_id, seat_show_id, seat_grade, seat_is_seat, seat_sector, seat_row, seat_col, seat_price, seat_show_date, + seat_show_round, seat_start_time, seat_status) +VALUES (3, 1, 'S', true, '2층', 'A', 2, 70000, '2024-01-01', 2, '13:00:00', 'AVAILABLE'); +-- +-- +INSERT INTO seat_table +(seat_id, seat_show_id, seat_grade, seat_is_seat, seat_sector, seat_row, seat_col, seat_price, seat_show_date, + seat_show_round, seat_start_time, seat_status) +VALUES (4, 2, 'VIP', true, '1층', 'A', 2, 100000, '2024-01-01', 2, '13:00:00', 'AVAILABLE'); +-- +-- +INSERT INTO seat_table +(seat_id, seat_show_id, seat_grade, seat_is_seat, seat_sector, seat_row, seat_col, seat_price, seat_show_date, + seat_show_round, seat_start_time, seat_status) +VALUES (5, 2, 'VIP', true, '1층', 'A', 3, 100000, '2024-01-01', 2, '13:00:00', 'AVAILABLE'); +-- +-- +INSERT INTO booking_table +(booking_id, booking_user_id, booking_show_id, booking_status, booking_ticket_count, created_at) +VALUES (1, 1, 1, 'BOOKED', 3, '2024-01-01'); +-- +-- +INSERT INTO ticket_table + (ticket_id, ticket_seat_id, booking_id) +VALUES (1, 1, 1); +-- +INSERT INTO ticket_table + (ticket_id, ticket_seat_id, booking_id) +VALUES (2, 2, 1); +-- +INSERT INTO ticket_table + (ticket_id, ticket_seat_id, booking_id) +VALUES (3, 3, 1); + +-- +INSERT INTO booking_table +(booking_id, booking_user_id, booking_show_id, booking_status, booking_ticket_count, created_at) +VALUES (2, 1, 2, 'BOOKED', 2, '2023-01-01'); +-- +INSERT INTO ticket_table + (ticket_id, ticket_seat_id, booking_id) +VALUES (4, 4, 2); +-- +INSERT INTO ticket_table + (ticket_id, ticket_seat_id, booking_id) +VALUES (5, 5, 2); From 63d66d50c7435c8222528d1ce4f180aa88856ef3 Mon Sep 17 00:00:00 2001 From: EunChanNam <75837025+EunChanNam@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:00:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[refactor]=20:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EC=A0=81=EB=9D=BD=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=98=20=EC=98=88=EB=A7=A4=EB=A5=BC=20Redis=20=EB=A5=BC=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=9C(=EB=B6=84=EC=82=B0=EB=9D=BDX)=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : Booking 분리돼있는 생성로직 하나로 병합 * refactor : 기존 비관적락 방식의 예매를 Redis(분산락X) 를 활용한 방식으로 마이그레이션 --- api/build.gradle | 1 + .../TicketBookingApiController.java | 5 +- .../BookingCancelApiControllerTest.java | 1 - .../hooon/common/support/ApiTestSupport.java | 10 ++ .../hooon/show/RankingApiControllerTest.java | 4 +- .../hooon/booking/aop/BookingConcurrency.java | 11 ++ .../aop/BookingConcurrencyHandlerAspect.java | 64 ++++++++ .../fascade/TicketBookingFacade.java | 54 +++---- .../hooon/booking/domain/entity/Booking.java | 14 +- .../hooon/booking/domain/entity/Ticket.java | 2 +- .../dev/hooon/common/config/AopConfig.java | 9 ++ .../hooon/show/application/SeatService.java | 5 +- .../adaptor/SeatRepositoryAdaptor.java | 2 +- .../repository/SeatJpaRepository.java | 7 +- .../application/BookingServiceTest.java | 11 +- .../TicketBookingFacadeIntegrationTest.java | 141 ++++++------------ .../fascade/TicketBookingFacadeTest.java | 9 +- .../repository/BookingJpaRepositoryTest.java | 4 +- .../domain/repository/SeatRepositoryTest.java | 11 +- .../adaptor/ShowRepositoryTest.java | 7 +- .../WaitingBookingServiceTest.java | 2 +- .../support/IntegrationTestSupport.java | 2 + .../hooon/common/config/SchedulerConfig.java | 2 + .../common/support/SchedulerTestSupport.java | 4 - .../show/scheduler/RankingSchedulerTest.java | 7 +- 25 files changed, 216 insertions(+), 173 deletions(-) create mode 100644 core/src/main/java/dev/hooon/booking/aop/BookingConcurrency.java create mode 100644 core/src/main/java/dev/hooon/booking/aop/BookingConcurrencyHandlerAspect.java create mode 100644 core/src/main/java/dev/hooon/common/config/AopConfig.java diff --git a/api/build.gradle b/api/build.gradle index edcb99fb..eeda5a41 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,6 +1,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework:spring-tx:6.1.1' + testImplementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' diff --git a/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java b/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java index 5c13fb11..e15a091b 100644 --- a/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java +++ b/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java @@ -32,7 +32,10 @@ public ResponseEntity bookingTicket( @Parameter(hidden = true) @JwtAuthorization Long userId, @Valid @RequestBody TicketBookingRequest ticketBookingRequest ) { - TicketBookingResponse ticketBookingResponse = ticketBookingFacade.bookingTicket(userId, ticketBookingRequest); + TicketBookingResponse ticketBookingResponse = ticketBookingFacade.bookingTicket( + userId, + ticketBookingRequest.seatIds() + ); return ResponseEntity.ok(ticketBookingResponse); } } diff --git a/api/src/test/java/dev/hooon/bookingcancel/BookingCancelApiControllerTest.java b/api/src/test/java/dev/hooon/bookingcancel/BookingCancelApiControllerTest.java index a66183dd..f2916874 100644 --- a/api/src/test/java/dev/hooon/bookingcancel/BookingCancelApiControllerTest.java +++ b/api/src/test/java/dev/hooon/bookingcancel/BookingCancelApiControllerTest.java @@ -43,7 +43,6 @@ private ResultActions doBooking() throws Exception { @DisplayName("사용자는 예매한 티켓을 취소할 수 있다.") @Test void cancelBookingTest() throws Exception { - // given User user = new User(); ReflectionTestUtils.setField(user, "id", 1L); diff --git a/api/src/test/java/dev/hooon/common/support/ApiTestSupport.java b/api/src/test/java/dev/hooon/common/support/ApiTestSupport.java index 5d422541..9d463b3b 100644 --- a/api/src/test/java/dev/hooon/common/support/ApiTestSupport.java +++ b/api/src/test/java/dev/hooon/common/support/ApiTestSupport.java @@ -2,9 +2,11 @@ import static org.springframework.http.MediaType.*; +import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -38,6 +40,14 @@ protected String toJson(Object object) throws JsonProcessingException { return objectMapper.writeValueAsString(object); } + @Autowired + private RedisTemplate redisTemplate; + + @AfterEach + public void cleanRedis() { + redisTemplate.delete(redisTemplate.keys("*")); + } + @PostConstruct public void setUpUser() throws Exception { // 캐싱해서 단 한번만 호출 diff --git a/api/src/test/java/dev/hooon/show/RankingApiControllerTest.java b/api/src/test/java/dev/hooon/show/RankingApiControllerTest.java index 48555b32..b1853216 100644 --- a/api/src/test/java/dev/hooon/show/RankingApiControllerTest.java +++ b/api/src/test/java/dev/hooon/show/RankingApiControllerTest.java @@ -11,7 +11,6 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import dev.hooon.booking.domain.entity.Booking; -import dev.hooon.booking.domain.entity.Ticket; import dev.hooon.booking.domain.repository.BookingRepository; import dev.hooon.common.fixture.SeatFixture; import dev.hooon.common.fixture.TestFixture; @@ -54,8 +53,7 @@ void getShowRanking_test() throws Exception { Seat seat = SeatFixture.getSeat(show); seatRepository.saveAll(List.of(seat)); - Booking booking = Booking.of(user, show); - booking.addTicket(Ticket.of(seat)); + Booking booking = Booking.of(user, show, List.of(seat)); bookingRepository.save(booking); //when diff --git a/core/src/main/java/dev/hooon/booking/aop/BookingConcurrency.java b/core/src/main/java/dev/hooon/booking/aop/BookingConcurrency.java new file mode 100644 index 00000000..245b247c --- /dev/null +++ b/core/src/main/java/dev/hooon/booking/aop/BookingConcurrency.java @@ -0,0 +1,11 @@ +package dev.hooon.booking.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BookingConcurrency { +} diff --git a/core/src/main/java/dev/hooon/booking/aop/BookingConcurrencyHandlerAspect.java b/core/src/main/java/dev/hooon/booking/aop/BookingConcurrencyHandlerAspect.java new file mode 100644 index 00000000..23543ee1 --- /dev/null +++ b/core/src/main/java/dev/hooon/booking/aop/BookingConcurrencyHandlerAspect.java @@ -0,0 +1,64 @@ +package dev.hooon.booking.aop; + +import static dev.hooon.booking.exception.BookingErrorCode.*; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import dev.hooon.common.exception.ValidationException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Aspect +@Component +@RequiredArgsConstructor +public class BookingConcurrencyHandlerAspect { + + private static final String KEY_PREFIX = "seat_"; + + private final RedisTemplate redisTemplate; + + private void validateIsPreemptibleSeat(List seatIds) { + List seatIdKeys = seatIds.stream().map(seatId -> KEY_PREFIX + seatId).toList(); + + List seatsData = redisTemplate.opsForValue().multiGet(seatIdKeys); + if (seatsData != null && seatsData.stream().anyMatch(Objects::nonNull)) { + throw new ValidationException(NOT_AVAILABLE_SEAT); + } + } + + private void validateIsPreemptibleSeatAndPreempt(List seatIds) { + Map seatIdKeyMap = seatIds.stream() + .collect(Collectors.toMap(seatId -> KEY_PREFIX + seatId, seatId -> true)); + + Boolean isPreempted = redisTemplate.opsForValue().multiSetIfAbsent(seatIdKeyMap); + if (Boolean.FALSE.equals(isPreempted)) { + throw new ValidationException(NOT_AVAILABLE_SEAT); + } + + seatIdKeyMap.keySet().forEach(key -> redisTemplate.expire(key, 30, TimeUnit.MINUTES)); + } + + @Transactional + @Around("@annotation(dev.hooon.booking.aop.BookingConcurrency) && args(Long, seatIds, ..)") + public Object handleBookingConcurrency( + ProceedingJoinPoint joinPoint, + List seatIds + ) throws Throwable { + + validateIsPreemptibleSeat(seatIds); + Object bookingResponse = joinPoint.proceed(); + validateIsPreemptibleSeatAndPreempt(seatIds); + + return bookingResponse; + } +} diff --git a/core/src/main/java/dev/hooon/booking/application/fascade/TicketBookingFacade.java b/core/src/main/java/dev/hooon/booking/application/fascade/TicketBookingFacade.java index 154f77a6..0cffc16b 100644 --- a/core/src/main/java/dev/hooon/booking/application/fascade/TicketBookingFacade.java +++ b/core/src/main/java/dev/hooon/booking/application/fascade/TicketBookingFacade.java @@ -6,15 +6,13 @@ import org.springframework.stereotype.Component; +import dev.hooon.booking.aop.BookingConcurrency; import dev.hooon.booking.application.BookingService; import dev.hooon.booking.domain.entity.Booking; -import dev.hooon.booking.domain.entity.Ticket; import dev.hooon.booking.dto.BookingMapper; -import dev.hooon.booking.dto.request.TicketBookingRequest; import dev.hooon.booking.dto.response.TicketBookingResponse; import dev.hooon.common.exception.ValidationException; import dev.hooon.show.application.SeatService; -import dev.hooon.show.domain.entity.Show; import dev.hooon.show.domain.entity.seat.Seat; import dev.hooon.user.application.UserService; import dev.hooon.user.domain.entity.User; @@ -29,13 +27,15 @@ public class TicketBookingFacade { private final SeatService seatService; private final UserService userService; - private static void validateAvailableSeat(Seat seat) { - if (!seat.isBookingAvailable()) { - throw new ValidationException(NOT_AVAILABLE_SEAT); - } + private void validateSeatsAbleToBook(List seats) { + seats.forEach(seat -> { + if (!seat.isBookingAvailable()) { + throw new ValidationException(NOT_AVAILABLE_SEAT); + } + }); } - private void validateSeatList(int seatIdsSize, List seatList) { + private void validateSeatsPresent(int seatIdsSize, List seatList) { if (seatList.isEmpty()) { throw new ValidationException(INVALID_EMPTY_SEAT); } @@ -45,37 +45,19 @@ private void validateSeatList(int seatIdsSize, List seatList) { } @Transactional - public TicketBookingResponse bookingTicket( - Long userId, - TicketBookingRequest ticketBookingRequest - ) { - - // 유저서비스에서 [유저] 가져옴 + @BookingConcurrency + public TicketBookingResponse bookingTicket(Long userId, List seatIds) { User user = userService.getUserById(userId); + List seats = seatService.findByIdIn(seatIds); - // 좌석서비스에서 [모든 좌석들] 가져옴 - List seatList = seatService.findByIdIn(ticketBookingRequest.seatIds()); - // 가져온 [모든 좌석들] validation - validateSeatList(ticketBookingRequest.seatIds().size(), seatList); - - // 좌석에서 [공연] 가져옴 - Show show = seatList.get(0).getShow(); - - // 모든 좌석들에 대해 [각 좌석] validation - seatList.forEach(TicketBookingFacade::validateAvailableSeat); - - // 예매 생성 - Booking booking = Booking.of(user, show); - - // 예매에 티켓리스트 넣기 - seatList.forEach(seat -> { - seat.markSeatStatusAsBooked(); - booking.addTicket(Ticket.of(seat)); - }); + // 좌석 검증 + validateSeatsPresent(seatIds.size(), seats); + validateSeatsAbleToBook(seats); - // 예매 저장 - Booking savedbooking = bookingService.bookingTicket(booking); + // 예매 생성 후 저장 & 좌석 상태 '예매됨' 상태로 변경 + Booking booking = Booking.of(user, seats.get(0).getShow(), seats); + bookingService.bookingTicket(booking); - return BookingMapper.toTicketBookingResponse(savedbooking); + return BookingMapper.toTicketBookingResponse(booking); } } diff --git a/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java b/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java index a007a141..ce0e22f3 100644 --- a/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java +++ b/core/src/main/java/dev/hooon/booking/domain/entity/Booking.java @@ -15,6 +15,7 @@ import dev.hooon.common.entity.TimeBaseEntity; import dev.hooon.show.domain.entity.Show; +import dev.hooon.show.domain.entity.seat.Seat; import dev.hooon.user.domain.entity.User; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -65,10 +66,16 @@ public void addTicket(Ticket ticket) { this.ticketCount++; } - private Booking(User user, Show show) { + private Booking(User user, Show show, List seats) { this.user = user; this.show = show; this.bookingStatus = BOOKED; + seats.forEach(seat -> { + seat.markSeatStatusAsBooked(); + Ticket ticket = Ticket.of(seat); + ticket.setBooking(this); + this.addTicket(ticket); + }); } private Booking(User user, Show show, LocalDateTime localDateTime) { @@ -80,9 +87,10 @@ private Booking(User user, Show show, LocalDateTime localDateTime) { public static Booking of( User user, - Show show + Show show, + List seats ) { - return new Booking(user, show); + return new Booking(user, show, seats); } public static Booking of( diff --git a/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java b/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java index 0e077f36..171ef725 100644 --- a/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java +++ b/core/src/main/java/dev/hooon/booking/domain/entity/Ticket.java @@ -37,7 +37,7 @@ public class Ticket extends TimeBaseEntity { private Seat seat; @ManyToOne - @JoinColumn(name = "booking_id") + @JoinColumn(name = "booking_id", nullable = false, foreignKey = @ForeignKey(value = NO_CONSTRAINT)) private Booking booking; private Ticket(Seat seat) { diff --git a/core/src/main/java/dev/hooon/common/config/AopConfig.java b/core/src/main/java/dev/hooon/common/config/AopConfig.java new file mode 100644 index 00000000..1232156d --- /dev/null +++ b/core/src/main/java/dev/hooon/common/config/AopConfig.java @@ -0,0 +1,9 @@ +package dev.hooon.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +@Configuration +@EnableAspectJAutoProxy +public class AopConfig { +} diff --git a/core/src/main/java/dev/hooon/show/application/SeatService.java b/core/src/main/java/dev/hooon/show/application/SeatService.java index d03dda82..0bc17c45 100644 --- a/core/src/main/java/dev/hooon/show/application/SeatService.java +++ b/core/src/main/java/dev/hooon/show/application/SeatService.java @@ -43,8 +43,9 @@ public void updateSeatToAvailable(Collection targetIds) { seatRepository.updateStatusByIdIn(targetIds, SeatStatus.AVAILABLE); } - public List findByIdIn(List idList) { - return seatRepository.findByIdIn(idList); + @Transactional(readOnly = true) + public List findByIdIn(List ids) { + return seatRepository.findByIdIn(ids); } public ShowSeatResponse getBookedSeatsInfo(BookedSeatQueryRequest request) { diff --git a/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java b/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java index f07ff5fa..18e9083f 100644 --- a/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java +++ b/core/src/main/java/dev/hooon/show/infrastructure/adaptor/SeatRepositoryAdaptor.java @@ -68,7 +68,7 @@ public List findSeatsByShowIdAndDateAndRoundAndGrade( @Override public List findByIdIn(List idList) { - return seatJpaRepository.findByIdIn(idList); + return seatJpaRepository.findWithShowByIdIn(idList); } @Override diff --git a/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java b/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java index 7d177ef5..8bad73e0 100644 --- a/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java +++ b/core/src/main/java/dev/hooon/show/infrastructure/repository/SeatJpaRepository.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -18,7 +18,6 @@ import dev.hooon.show.dto.query.SeatDateRoundDto; import dev.hooon.show.dto.query.seats.SeatsDetailDto; import dev.hooon.show.dto.query.seats.SeatsInfoDto; -import jakarta.persistence.LockModeType; public interface SeatJpaRepository extends JpaRepository { @@ -67,8 +66,8 @@ List findSeatsByShowIdAndDateAndRoundAndGrade( @Param("grade") SeatGrade grade ); - @Lock(LockModeType.PESSIMISTIC_WRITE) - List findByIdIn(List idList); + @EntityGraph(attributePaths = "show") + List findWithShowByIdIn(List idList); @Query(""" select new dev.hooon.show.dto.query.seats.SeatsDetailDto( diff --git a/core/src/test/java/dev/hooon/booking/application/BookingServiceTest.java b/core/src/test/java/dev/hooon/booking/application/BookingServiceTest.java index 0a49e003..0f238ef5 100644 --- a/core/src/test/java/dev/hooon/booking/application/BookingServiceTest.java +++ b/core/src/test/java/dev/hooon/booking/application/BookingServiceTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -16,7 +17,6 @@ import dev.hooon.booking.domain.entity.Booking; import dev.hooon.booking.domain.entity.BookingStatus; -import dev.hooon.booking.domain.entity.Ticket; import dev.hooon.booking.domain.repository.BookingRepository; import dev.hooon.booking.dto.response.BookingCancelResponse; import dev.hooon.common.exception.NotFoundException; @@ -62,7 +62,7 @@ void cancelBooking_fail_test_2() { long anotherUserId = 2L; long userId = 1L; User user = TestFixture.getUser(userId); - Booking booking = Booking.of(user, TestFixture.getShow(TestFixture.getPlace())); + Booking booking = Booking.of(user, TestFixture.getShow(TestFixture.getPlace()), new ArrayList<>()); given(bookingRepository.findByIdWithTickets(bookingId)).willReturn(Optional.of(booking)); @@ -81,7 +81,7 @@ void cancelBooking_fail_test_3() { long bookingId = 1L; long userId = 1L; User user = TestFixture.getUser(userId); - Booking booking = Booking.of(user, TestFixture.getShow(TestFixture.getPlace())); + Booking booking = Booking.of(user, TestFixture.getShow(TestFixture.getPlace()), new ArrayList<>()); booking.markBookingStatusAsCanceled(); given(bookingRepository.findByIdWithTickets(bookingId)).willReturn(Optional.of(booking)); @@ -108,10 +108,7 @@ void cancelBooking_success_test() { show.getShowPeriod().getStartDate(), 1 ); - Booking booking = Booking.of(user, show); - allBookedSeats.forEach( - seat -> booking.addTicket(Ticket.of(seat)) - ); + Booking booking = Booking.of(user, show, allBookedSeats); given(bookingRepository.findByIdWithTickets(bookingId)).willReturn(Optional.of(booking)); // when diff --git a/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeIntegrationTest.java b/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeIntegrationTest.java index f3d6c885..03a7e9ff 100644 --- a/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeIntegrationTest.java +++ b/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeIntegrationTest.java @@ -4,11 +4,9 @@ import static dev.hooon.user.domain.entity.UserRole.*; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.*; -import java.util.ArrayList; import java.util.List; -import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -16,11 +14,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; import dev.hooon.booking.domain.entity.Booking; -import dev.hooon.booking.dto.request.TicketBookingRequest; import dev.hooon.booking.dto.response.TicketBookingResponse; import dev.hooon.booking.infrastructure.repository.BookingJpaRepository; import dev.hooon.common.exception.ValidationException; @@ -37,7 +34,6 @@ @DisplayName("[TicketBookingFacade 통합 테스트]") @SpringBootTest -@AutoConfigureTestDatabase(replace = NONE) class TicketBookingFacadeIntegrationTest extends TestContainerSupport { @Autowired @@ -58,6 +54,9 @@ class TicketBookingFacadeIntegrationTest extends TestContainerSupport { @Autowired private BookingJpaRepository bookingRepository; + @Autowired + private RedisTemplate redisTemplate; + @AfterEach void databaseCleanUp() { bookingRepository.deleteAll(); @@ -65,9 +64,11 @@ void databaseCleanUp() { userRepository.deleteAll(); showRepository.deleteAll(); seatRepository.deleteAll(); + + redisTemplate.delete("*"); } - @DisplayName("좌석 예매 성공 시, 예매된 모든 좌석의 seatStatus는 'BOOKED' 가 된다") + @DisplayName("좌석 예매 성공 시, 예매된 모든 좌석의 seatStatus 는 'BOOKED' 가 된다") @Test void bookingTicket_success_test() { @@ -89,8 +90,7 @@ void bookingTicket_success_test() { .toList(); // when - TicketBookingResponse ticketBookingResponse = ticketBookingFacade.bookingTicket(user.getId(), - new TicketBookingRequest(idList)); + TicketBookingResponse ticketBookingResponse = ticketBookingFacade.bookingTicket(user.getId(), idList); // then assertThat(ticketBookingResponse.bookingTickets()).hasSize(5); @@ -123,7 +123,7 @@ void bookingTicket_fail_test_1() { // when, then assertThrows( ValidationException.class, - () -> ticketBookingFacade.bookingTicket(user.getId(), new TicketBookingRequest(idList)), + () -> ticketBookingFacade.bookingTicket(user.getId(), idList), NOT_AVAILABLE_SEAT.getMessage() ); } @@ -152,50 +152,15 @@ void bookingTicket_fail_test_2() { // when, then assertThrows( ValidationException.class, - () -> ticketBookingFacade.bookingTicket(user.getId(), new TicketBookingRequest(idList)), + () -> ticketBookingFacade.bookingTicket(user.getId(), idList), INVALID_SELECTED_SEAT.getMessage() ); } - @DisplayName("동시에 같은 좌석들을 예매할 때 예매는 1건만 만들어진다") - @Test - void bookingTicket_concurrency_test_1() throws InterruptedException { - - // given - ExecutorService executorService = Executors.newFixedThreadPool(100); - - // User user = new User("user@email.com", "user", BUYER); - User user = User.ofBuyer("user@email.com", "user", BUYER.toString()); - userRepository.save(user); - - Place place = TestFixture.getPlace(); - placeRepository.save(place); - - Show show = TestFixture.getShow(place); - showRepository.save(show); - - List seats = TestFixture.getSeatList(show, show.getShowPeriod().getStartDate(), 1); - List savedSeats = seatRepository.saveAll(seats); - List idList = savedSeats.stream() - .map(Seat::getId) - .toList(); - - // when - executorService.invokeAll(getCallables1(user, idList)); - - // then - List bookingList = bookingRepository.findAll(); - assertEquals(1, bookingList.size()); - } - - @DisplayName("동시에 여러 예매가 진행될 때, 겹치는 좌석이 있으면 예매는 그 중 한 건만 만들어진다") @Test - void bookingTicket_concurrency_test_2() throws InterruptedException { - - // given - ExecutorService executorService = Executors.newFixedThreadPool(3); - - // User user = new User("user@email.com", "user", BUYER); + @DisplayName("[동시에 100개의 예매를 할 때, 단 한건의 예매만 성공한다]") + void bookingTicket_concurrency_test() throws InterruptedException { + //given User user = User.ofBuyer("user@email.com", "user", BUYER.toString()); userRepository.save(user); @@ -206,52 +171,44 @@ void bookingTicket_concurrency_test_2() throws InterruptedException { showRepository.save(show); List seats = TestFixture.getSeatList(show, show.getShowPeriod().getStartDate(), 1); - List savedSeats = seatRepository.saveAll(seats); - List idList = savedSeats.stream() - .map(Seat::getId) - .toList(); + seatRepository.saveAll(seats); + List seatIds = seats.stream().map(Seat::getId).toList(); + List seatIds1 = List.of(seatIds.get(0), seatIds.get(1), seatIds.get(2)); + List seatIds2 = List.of(seatIds.get(2), seatIds.get(3), seatIds.get(4)); + + int threadCount = 100; + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + + //when + for (int i = 0; i < 50; i++) { + executorService.execute(() -> { + try { + ticketBookingFacade.bookingTicket(user.getId(), seatIds1); + } catch (ValidationException e) { + // 예외 catch 해서 정상흐름 + } + + countDownLatch.countDown(); + }); + } - // when - executorService.invokeAll(getCallables2(user, idList)); + for (int i = 0; i < 50; i++) { + executorService.execute(() -> { + try { + ticketBookingFacade.bookingTicket(user.getId(), seatIds2); + } catch (ValidationException e) { + // 예외 catch 해서 정상흐름 + } - // then - List bookingList = bookingRepository.findAll(); - assertEquals(2, bookingList.size()); - } - - /** - * 동시에 같은 좌석들을 예매하는 다중 스레드를 요청합니다 - * - * @param user - * @param idList - * @return - */ - private List> getCallables1(User user, List idList) { - List> callables = new ArrayList<>(); - for (int i = 0; i < 100; i++) { - callables.add(() -> ticketBookingFacade.bookingTicket(user.getId(), new TicketBookingRequest(idList))); + countDownLatch.countDown(); + }); } - return callables; - } - /** - * 겹치는 좌석들을 예매하는 다중 스레드를 요청합니다 - * - * @param user - * @param idList - * @return - */ - private List> getCallables2(User user, List idList) { - List> callables = new ArrayList<>(); - callables.add( - () -> ticketBookingFacade.bookingTicket(user.getId(), - new TicketBookingRequest(List.of(idList.get(0), idList.get(1), idList.get(2))))); - callables.add( - () -> ticketBookingFacade.bookingTicket(user.getId(), - new TicketBookingRequest(List.of(idList.get(2), idList.get(3))))); - callables.add( - () -> ticketBookingFacade.bookingTicket(user.getId(), new TicketBookingRequest(List.of(idList.get(4))))); - return callables; - } + countDownLatch.await(); + //then + List allBooking = bookingRepository.findAll(); + assertThat(allBooking).hasSize(1); + } } diff --git a/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeTest.java b/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeTest.java index c823c1ee..38e57724 100644 --- a/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeTest.java +++ b/core/src/test/java/dev/hooon/booking/application/fascade/TicketBookingFacadeTest.java @@ -41,7 +41,7 @@ class TicketBookingFacadeTest { @Mock private UserService userService; - @DisplayName("seatList가 비어있으면 예외가 발생한다") + @DisplayName("seatList 가 비어있으면 예외가 발생한다") @Test void validateSeatList_test_1() { @@ -55,7 +55,7 @@ void validateSeatList_test_1() { // when, then assertThrows( ValidationException.class, - () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest), + () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest.seatIds()), INVALID_EMPTY_SEAT.getMessage() ); } @@ -75,7 +75,7 @@ void validateSeatList_test_2() { // when, then assertThrows( ValidationException.class, - () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest), + () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest.seatIds()), INVALID_SELECTED_SEAT.getMessage() ); } @@ -100,9 +100,8 @@ void validateAvailableSeat_test() { // when, then assertThrows( ValidationException.class, - () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest), + () -> ticketBookingFacade.bookingTicket(1L, ticketBookingRequest.seatIds()), NOT_AVAILABLE_SEAT.getMessage() ); } - } diff --git a/core/src/test/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepositoryTest.java b/core/src/test/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepositoryTest.java index 9cee1202..f1331c10 100644 --- a/core/src/test/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepositoryTest.java +++ b/core/src/test/java/dev/hooon/booking/infrastructure/repository/BookingJpaRepositoryTest.java @@ -55,9 +55,7 @@ void findByIdWithTickets_test() { Seat seat1 = SeatFixture.getSeat(); Seat seat2 = SeatFixture.getSeat(); seatRepository.saveAll(List.of(seat1, seat2)); - Booking booking = Booking.of(user, show); - booking.addTicket(Ticket.of(seat1)); - booking.addTicket(Ticket.of(seat2)); + Booking booking = Booking.of(user, show, List.of(seat1, seat2)); Booking savedBooking = bookingRepository.save(booking); // when diff --git a/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java b/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java index 79e3d871..6797189f 100644 --- a/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java +++ b/core/src/test/java/dev/hooon/show/domain/repository/SeatRepositoryTest.java @@ -152,10 +152,15 @@ void findShowNameById_test() { @DisplayName("[id 리스트에 포함된 좌석들을 조회할 수 있다]") void findByIdInTest() { // given + Place place = TestFixture.getPlace(); + placeRepository.save(place); + Show show = TestFixture.getShow(place); + showRepository.save(show); + List seats = List.of( - SeatFixture.getSeat(), - SeatFixture.getSeat(), - SeatFixture.getSeat() + SeatFixture.getSeat(show), + SeatFixture.getSeat(show), + SeatFixture.getSeat(show) ); seatRepository.saveAll(seats); diff --git a/core/src/test/java/dev/hooon/show/infrastructure/adaptor/ShowRepositoryTest.java b/core/src/test/java/dev/hooon/show/infrastructure/adaptor/ShowRepositoryTest.java index 0f292053..069732a1 100644 --- a/core/src/test/java/dev/hooon/show/infrastructure/adaptor/ShowRepositoryTest.java +++ b/core/src/test/java/dev/hooon/show/infrastructure/adaptor/ShowRepositoryTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -94,9 +95,9 @@ void findShowStatistic_test() { shows.forEach(show -> showRepository.save(show)); List bookings = List.of( - Booking.of(user, shows.get(1)), - Booking.of(user, shows.get(2)), - Booking.of(user, shows.get(1)) + Booking.of(user, shows.get(1), new ArrayList<>()), + Booking.of(user, shows.get(2), new ArrayList<>()), + Booking.of(user, shows.get(1), new ArrayList<>()) ); bookings.forEach(this::setTicketCount); bookings.forEach(booking -> bookingRepository.save(booking)); diff --git a/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java b/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java index 19e301d9..0c5daf18 100644 --- a/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java +++ b/core/src/test/java/dev/hooon/waitingbooking/application/WaitingBookingServiceTest.java @@ -14,8 +14,8 @@ import dev.hooon.user.domain.entity.User; import dev.hooon.waitingbooking.domain.entity.WaitingBooking; -import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.SelectedSeat; import dev.hooon.waitingbooking.domain.entity.WaitingStatus; +import dev.hooon.waitingbooking.domain.entity.waitingbookingseat.SelectedSeat; import dev.hooon.waitingbooking.domain.repository.WaitingBookingRepository; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; diff --git a/core/src/testFixtures/java/dev/hooon/common/support/IntegrationTestSupport.java b/core/src/testFixtures/java/dev/hooon/common/support/IntegrationTestSupport.java index 0df7bd29..0eb5434f 100644 --- a/core/src/testFixtures/java/dev/hooon/common/support/IntegrationTestSupport.java +++ b/core/src/testFixtures/java/dev/hooon/common/support/IntegrationTestSupport.java @@ -6,10 +6,12 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureTestEntityManager; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @SpringBootTest @AutoConfigureTestEntityManager +@ActiveProfiles("test") @Transactional public abstract class IntegrationTestSupport extends TestContainerSupport { diff --git a/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java b/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java index 06e8e389..4447de11 100644 --- a/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java +++ b/scheduler/src/main/java/dev/hooon/common/config/SchedulerConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @@ -10,6 +11,7 @@ @Configuration @EnableScheduling @EnableAsync +@Profile("!test") public class SchedulerConfig { @Bean("scheduler") diff --git a/scheduler/src/test/java/dev/hooon/common/support/SchedulerTestSupport.java b/scheduler/src/test/java/dev/hooon/common/support/SchedulerTestSupport.java index 659112f8..c9febb25 100644 --- a/scheduler/src/test/java/dev/hooon/common/support/SchedulerTestSupport.java +++ b/scheduler/src/test/java/dev/hooon/common/support/SchedulerTestSupport.java @@ -2,14 +2,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; -import dev.hooon.common.config.SchedulerConfig; import dev.hooon.mail.event.WaitingBookingMailEventListener; public abstract class SchedulerTestSupport extends IntegrationTestSupport { @MockBean protected WaitingBookingMailEventListener eventListener; - - @MockBean - private SchedulerConfig schedulerConfig; } diff --git a/scheduler/src/test/java/dev/hooon/show/scheduler/RankingSchedulerTest.java b/scheduler/src/test/java/dev/hooon/show/scheduler/RankingSchedulerTest.java index fb2c45bb..7e17a71a 100644 --- a/scheduler/src/test/java/dev/hooon/show/scheduler/RankingSchedulerTest.java +++ b/scheduler/src/test/java/dev/hooon/show/scheduler/RankingSchedulerTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.*; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -58,9 +59,9 @@ void cacheEvictRanking() { userRepository.save(user); List bookings = List.of( - Booking.of(user, shows.get(0)), - Booking.of(user, shows.get(1)), - Booking.of(user, shows.get(2)) + Booking.of(user, shows.get(0), new ArrayList<>()), + Booking.of(user, shows.get(1), new ArrayList<>()), + Booking.of(user, shows.get(2), new ArrayList<>()) ); bookings.forEach(booking -> bookingRepository.save(booking)); From 2332c1050b2eed781552e826c45055a979adc95d Mon Sep 17 00:00:00 2001 From: ParkJuHan <1004sunye@naver.com> Date: Sun, 14 Jan 2024 19:01:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[feat]=20:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20api=20=EA=B5=AC=ED=98=84=20+=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feat]: 로그 어노테이션 제거, auth 엔티티에 auditing 추가 * [feat]: AuthService 에서 로그아웃 구현과 테스트코드 * [feat]: BlacklistToken 엔티티와 레포지토리 구현 * [feat]: 로그아웃 api 와 테스트코드 구 --- .../dev/hooon/auth/AuthApiController.java | 14 +++++++- .../dev/hooon/auth/AuthApiControllerTest.java | 23 +++++++++--- .../hooon/auth/application/AuthService.java | 23 ++++++++++-- .../dev/hooon/auth/domain/entity/Auth.java | 7 ++-- .../auth/domain/entity/BlacklistToken.java | 35 +++++++++++++++++++ .../repository/BlacklistRepository.java | 10 ++++++ .../hooon/auth/exception/AuthErrorCode.java | 3 +- .../BlacklistJpaRepository.java | 9 +++++ .../adaptor/BlacklistRepositoryAdaptor.java | 25 +++++++++++++ .../auth/application/AuthServiceTest.java | 34 ++++++++++++++++++ 10 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java create mode 100644 core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java create mode 100644 core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java create mode 100644 core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java diff --git a/api/src/main/java/dev/hooon/auth/AuthApiController.java b/api/src/main/java/dev/hooon/auth/AuthApiController.java index def6a417..d9f81d96 100644 --- a/api/src/main/java/dev/hooon/auth/AuthApiController.java +++ b/api/src/main/java/dev/hooon/auth/AuthApiController.java @@ -1,5 +1,6 @@ package dev.hooon.auth; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -10,9 +11,10 @@ import dev.hooon.auth.application.AuthService; import dev.hooon.auth.dto.TokenReIssueRequest; import dev.hooon.auth.dto.request.AuthRequest; - import dev.hooon.auth.dto.response.AuthResponse; +import dev.hooon.auth.jwt.JwtAuthorization; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -37,6 +39,16 @@ public ResponseEntity login( return ResponseEntity.ok(authResponse); } + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "로그아웃을 한다") + @ApiResponse(responseCode = "200", useReturnTypeSchema = true) + public ResponseEntity logout( + @Parameter(hidden = true) @JwtAuthorization Long userId + ) { + authService.logout(userId); + return ResponseEntity.ok(HttpStatus.OK); + } + @NoAuth @PostMapping("/token") @Operation(summary = "토큰 재발급 API", description = "토큰을 재발급한다") diff --git a/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java index 8089b577..39cc6cc6 100644 --- a/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java +++ b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java @@ -26,19 +26,18 @@ class AuthApiControllerTest extends ApiTestSupport { private UserService userService; @Autowired private AuthService authService; + private AuthRequest authRequest; @BeforeEach void setUp() { UserJoinRequest userJoinRequest = new UserJoinRequest("user@example.com", "password123", "name123"); userService.join(userJoinRequest); + authRequest = new AuthRequest("user@example.com", "password123"); } @Test @DisplayName("[로그인 API를 호출하면 토큰이 응답된다]") void loginTest() throws Exception { - // given - AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); - // when ResultActions actions = mockMvc.perform( post("/api/auth/login") @@ -56,7 +55,6 @@ void loginTest() throws Exception { @DisplayName("[토큰 재발급 API를 호출하면 새로운 엑세스 토큰이 응답된다]") void reIssueAccessTokenTest() throws Exception { // given - AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); AuthResponse authResponse = authService.login(authRequest); String refreshToken = authResponse.refreshToken(); TokenReIssueRequest tokenReIssueRequest = new TokenReIssueRequest(refreshToken); @@ -72,4 +70,21 @@ void reIssueAccessTokenTest() throws Exception { actions.andExpect(status().isOk()) .andExpect(content().string(not(emptyOrNullString()))); } + + @Test + @DisplayName("[로그아웃 API를 호출하면 200 OK 응답이 반환된다]") + void logoutTest() throws Exception { + // given + AuthResponse authResponse = authService.login(authRequest); + String accessToken = authResponse.accessToken(); + + // when + ResultActions actions = mockMvc.perform( + post("/api/auth/logout") + .header("Authorization", accessToken) + ); + + // then + actions.andExpect(status().isOk()); + } } diff --git a/core/src/main/java/dev/hooon/auth/application/AuthService.java b/core/src/main/java/dev/hooon/auth/application/AuthService.java index bfa1fb4b..36498bfe 100644 --- a/core/src/main/java/dev/hooon/auth/application/AuthService.java +++ b/core/src/main/java/dev/hooon/auth/application/AuthService.java @@ -1,4 +1,3 @@ - package dev.hooon.auth.application; import static dev.hooon.auth.exception.AuthErrorCode.*; @@ -9,10 +8,13 @@ import org.springframework.transaction.annotation.Transactional; import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.entity.BlacklistToken; import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.domain.repository.BlacklistRepository; import dev.hooon.auth.dto.request.AuthRequest; import dev.hooon.auth.dto.response.AuthResponse; import dev.hooon.auth.entity.EncryptHelper; +import dev.hooon.auth.exception.AuthException; import dev.hooon.common.exception.NotFoundException; import dev.hooon.user.application.UserService; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class AuthService { private final JwtProvider jwtProvider; private final AuthRepository authRepository; private final EncryptHelper encryptHelper; + private final BlacklistRepository blacklistRepository; private Auth getAuthByRefreshToken(String refreshToken) { return authRepository.findByRefreshToken(refreshToken) @@ -37,7 +40,7 @@ private AuthResponse saveAuth(Long userId) { Optional auth = authRepository.findByUserId(userId); auth.ifPresentOrElse( - (none) -> authRepository.updateRefreshToken(auth.get().getId(), refreshToken), + existingAuth -> authRepository.updateRefreshToken(existingAuth.getId(), refreshToken), () -> { Auth newAuth = Auth.of(userId, refreshToken); authRepository.save(newAuth); @@ -60,7 +63,23 @@ public AuthResponse login(AuthRequest authRequest) { throw new NotFoundException(FAILED_LOGIN_BY_ANYTHING); } + @Transactional + public void logout(Long userId) { + authRepository.findByUserId(userId).ifPresentOrElse( + auth -> + blacklistRepository.save(BlacklistToken.of(auth.getRefreshToken())), + () -> { + throw new NotFoundException(NOT_FOUND_USER_ID); + } + ); + } + public String createAccessTokenByRefreshToken(String refreshToken) { + boolean isBlacklisted = blacklistRepository.existsByRefreshToken(refreshToken); + if (isBlacklisted) { + throw new AuthException(BLACKLISTED_TOKEN); + } + Auth auth = getAuthByRefreshToken(refreshToken); Long userId = userService.getUserById(auth.getUserId()).getId(); return jwtProvider.createAccessToken(userId); diff --git a/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java index 92fe7afb..62f536f5 100644 --- a/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java +++ b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java @@ -1,11 +1,8 @@ package dev.hooon.auth.domain.entity; -import static dev.hooon.common.exception.CommonValidationError.*; import static jakarta.persistence.GenerationType.*; -import org.springframework.util.Assert; - -import dev.hooon.user.domain.entity.UserRole; +import dev.hooon.common.entity.TimeBaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +15,7 @@ @Getter @NoArgsConstructor @Table(name = "auth_table") -public class Auth { +public class Auth extends TimeBaseEntity { @Id @GeneratedValue(strategy = IDENTITY) diff --git a/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java b/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java new file mode 100644 index 00000000..0f926cc5 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java @@ -0,0 +1,35 @@ +package dev.hooon.auth.domain.entity; + +import static jakarta.persistence.GenerationType.*; + +import dev.hooon.common.entity.TimeBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "blacklist_token_table") +public class BlacklistToken extends TimeBaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "blacklist_token_id") + private Long id; + + @Column(name = "blacklist_token_refresh_token", nullable = false, unique = true) + private String refreshToken; + + private BlacklistToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public static BlacklistToken of(String refreshToken) { + return new BlacklistToken(refreshToken); + } +} diff --git a/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java b/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java new file mode 100644 index 00000000..e58d53ce --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java @@ -0,0 +1,10 @@ +package dev.hooon.auth.domain.repository; + +import dev.hooon.auth.domain.entity.BlacklistToken; + +public interface BlacklistRepository { + + boolean existsByRefreshToken(String refreshToken); + + void save(BlacklistToken blacklistToken); +} diff --git a/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java index 687ee99f..dbaa0217 100644 --- a/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java +++ b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java @@ -17,7 +17,8 @@ public enum AuthErrorCode implements ErrorCode { NOT_FOUND_USER_ID("해당 유저의 인증 데이터가 존재하지 않습니다.", "A_007"), TOKEN_EXPIRED("토큰이 만료 시간을 초과했습니다.", "A_008"), UNSUPPORTED_TOKEN("토큰 유형이 지원되지 않습니다.", "A_010"), - MALFORMED_TOKEN("토큰의 구조가 올바르지 않습니다.", "A_011"); + MALFORMED_TOKEN("토큰의 구조가 올바르지 않습니다.", "A_011"), + BLACKLISTED_TOKEN("해당 토큰은 블랙리스트에 등록되어있으므로 유효하지 않습니다.", "A_012"); private final String message; private final String code; diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java b/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java new file mode 100644 index 00000000..a8e0c294 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java @@ -0,0 +1,9 @@ +package dev.hooon.auth.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +import dev.hooon.auth.domain.entity.BlacklistToken; + +public interface BlacklistJpaRepository extends JpaRepository { + boolean existsByRefreshToken(String refreshToken); +} diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java new file mode 100644 index 00000000..b8ed2108 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java @@ -0,0 +1,25 @@ +package dev.hooon.auth.infrastructure.adaptor; + +import org.springframework.stereotype.Repository; + +import dev.hooon.auth.domain.entity.BlacklistToken; +import dev.hooon.auth.domain.repository.BlacklistRepository; +import dev.hooon.auth.infrastructure.BlacklistJpaRepository; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class BlacklistRepositoryAdaptor implements BlacklistRepository { + + private final BlacklistJpaRepository blacklistJpaRepository; + + @Override + public boolean existsByRefreshToken(String refreshToken) { + return blacklistJpaRepository.existsByRefreshToken(refreshToken); + } + + @Override + public void save(BlacklistToken blacklistToken) { + blacklistJpaRepository.save(blacklistToken); + } +} diff --git a/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java index ea2f9a02..e43a44b9 100644 --- a/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java +++ b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java @@ -1,5 +1,7 @@ package dev.hooon.auth.application; +import static dev.hooon.auth.exception.AuthErrorCode.*; +import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -13,7 +15,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.entity.BlacklistToken; import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.domain.repository.BlacklistRepository; import dev.hooon.auth.dto.request.AuthRequest; import dev.hooon.auth.dto.response.AuthResponse; import dev.hooon.auth.entity.EncryptHelper; @@ -36,6 +40,8 @@ class AuthServiceTest { private JwtProvider jwtProvider; @Mock private EncryptHelper encryptHelper; + @Mock + private BlacklistRepository blacklistRepository; @Test @DisplayName("[로그인 성공 시 토큰을 발급한다]") @@ -74,4 +80,32 @@ void loginFailTest() { // when & then assertThrows(NotFoundException.class, () -> authService.login(authRequest)); } + + @Test + @DisplayName("로그아웃 성공 시 블랙리스트에 토큰을 추가한다") + void logoutSuccessTest() { + // given + Long userId = 1L; + Auth auth = Auth.of(userId, "refresh-token"); + when(authRepository.findByUserId(userId)).thenReturn(Optional.of(auth)); + + // when + authService.logout(userId); + + // then + verify(blacklistRepository).save(any(BlacklistToken.class)); + } + + @Test + @DisplayName("로그아웃 실패 시 NotFoundException을 던진다") + void logoutFailTest() { + // given + Long userId = 1L; + when(authRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.logout(userId)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(NOT_FOUND_USER_ID.getMessage()); + } }