-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feat] 예약 현황 조회 / 예약 하기 기능 구현 #12
Changes from all commits
574dc77
4dcad0c
6c5fb51
5d3a964
c4a99dc
7a7adb9
b0c7c99
bcddbd1
a7d6ca9
f03c0a1
d2bf6e2
f12bcfd
a96e0cf
eb29182
8eacd65
5d0cdf3
132c467
8d3d7eb
e6eefc4
08af7c7
4d3f337
25169fa
fed1799
bc18e22
97f7890
9360d0c
ff77318
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
package com.nowait.booking.application; | ||
|
||
import com.nowait.booking.domain.model.Booking; | ||
import com.nowait.common.event.BookedEvent; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.context.ApplicationEventPublisher; | ||
import org.springframework.scheduling.annotation.Async; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class BookingEventPublisher { | ||
|
||
private final ApplicationEventPublisher eventPublisher; | ||
|
||
@Async | ||
public void publishBookedEvent(Booking booking, Long placeId) { | ||
eventPublisher.publishEvent( | ||
new BookedEvent(booking.getId(), placeId, booking.getUserId())); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
package com.nowait.booking.application; | ||
|
||
import com.nowait.booking.domain.model.Booking; | ||
import com.nowait.booking.domain.model.BookingSlot; | ||
import com.nowait.booking.domain.repository.BookingRepository; | ||
import com.nowait.booking.domain.repository.BookingSlotRepository; | ||
import com.nowait.booking.dto.TimeSlotDto; | ||
import com.nowait.booking.dto.response.BookingRes; | ||
import com.nowait.booking.dto.response.DailyBookingStatusRes; | ||
import com.nowait.place.domain.repository.PlaceRepository; | ||
import com.nowait.user.domain.repository.UserRepository; | ||
import jakarta.persistence.EntityNotFoundException; | ||
import java.time.LocalDate; | ||
import java.time.LocalTime; | ||
import java.util.List; | ||
import java.util.stream.Collectors; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
@Transactional(readOnly = true) | ||
public class BookingService { | ||
|
||
private final BookingSlotRepository bookingSlotRepository; | ||
private final BookingRepository bookingRepository; | ||
private final UserRepository userRepository; | ||
private final PlaceRepository placeRepository; | ||
Comment on lines
+28
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋🏻♀️ 추후에 User / Place / Booking 모듈이 따로 배포될 정도로 확장이 되는 경우를 생각하면 이렇게 바로 Repository를 의존하면 안될 것 같은데.. 너무 먼 미래를 걱정하는 걸까요? 😂 |
||
private final BookingEventPublisher bookingEventPublisher; | ||
|
||
public DailyBookingStatusRes getDailyBookingStatus(Long placeId, LocalDate date) { | ||
List<BookingSlot> bookingSlots = bookingSlotRepository.findAllByPlaceIdAndDate( | ||
placeId, date); | ||
|
||
List<TimeSlotDto> timeSlots = bookingSlots.stream() | ||
.collect(Collectors.groupingBy(BookingSlot::getTime)) | ||
.entrySet().stream() | ||
.map(entry -> new TimeSlotDto(entry.getKey(), isAvailable(entry.getValue()))) | ||
.toList(); | ||
|
||
return new DailyBookingStatusRes(placeId, date, timeSlots); | ||
} | ||
|
||
@Transactional | ||
public BookingRes book(Long loginId, Long placeId, LocalDate date, LocalTime time, | ||
Integer partySize) { | ||
validateUserExist(loginId, "존재하지 않는 사용자의 요청입니다."); | ||
validatePlaceExist(placeId, "존재하지 않는 식당입니다."); | ||
|
||
BookingSlot slot = findAvailableSlot(placeId, date, time); | ||
Booking booking = bookingRepository.save(Booking.of(loginId, partySize, slot)); | ||
|
||
bookingEventPublisher.publishBookedEvent(booking, placeId); | ||
|
||
return BookingRes.of(booking, slot); | ||
} | ||
|
||
private boolean isAvailable(List<BookingSlot> slots) { | ||
// 모든 슬롯이 예약된 경우에만 false 반환 | ||
return slots.stream().anyMatch(slot -> !slot.isBooked()); | ||
} | ||
|
||
private void validateUserExist(Long userId, String errorMessage) { | ||
if (!userRepository.existsById(userId)) { | ||
throw new EntityNotFoundException(errorMessage); | ||
} | ||
} | ||
|
||
private void validatePlaceExist(Long placeId, String errorMessage) { | ||
if (!placeRepository.existsById(placeId)) { | ||
throw new EntityNotFoundException(errorMessage); | ||
} | ||
} | ||
|
||
private BookingSlot findAvailableSlot(Long placeId, LocalDate date, LocalTime time) { | ||
return bookingSlotRepository.findFirstByPlaceIdAndDateAndTimeAndIsBookedFalse(placeId, date, | ||
time).orElseThrow(() -> new IllegalArgumentException("예약 가능한 테이블이 없습니다.")); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package com.nowait.booking.domain.model; | ||
|
||
import com.nowait.common.domain.model.BaseTimeEntity; | ||
import jakarta.persistence.Column; | ||
import jakarta.persistence.Entity; | ||
import jakarta.persistence.EnumType; | ||
import jakarta.persistence.Enumerated; | ||
import jakarta.persistence.GeneratedValue; | ||
import jakarta.persistence.GenerationType; | ||
import jakarta.persistence.Id; | ||
import jakarta.persistence.Table; | ||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Entity | ||
@Table(name = "booking") | ||
@Getter | ||
@AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
public class Booking extends BaseTimeEntity { | ||
|
||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
@Column(name = "id") | ||
private Long id; | ||
|
||
@Column(name = "booking_slot_id", nullable = false, unique = true) | ||
private Long bookingSlotId; | ||
Comment on lines
+29
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋🏻♀️ 여러명이 동시에 예약을 하는 경우에 낙관적 락이나 레디스를 통해 해결해야겠다! 라고 생각했었는데, 개발을 하다보니 그냥 bookingSlotId에 unique를 걸어두면, 데이터베이스에 절대 같은 날짜/시간/테이블을 예약할 일이 없더라구요! 실무(?)에서는 어떤 방식으로 동시 예약을 막는지 여쭤봐도 될까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋🏻♀️ 엔티티 간에 관계에서 객체를 사용하지 않고, id 값을 가지도록 했는데요! 그 이유는 1️⃣ 객체를 가지고 있으면 엔티티 간에 결합도가 높아져서 추후 모듈을 분리하기 어려울 것 같다고 판단을 하였고, 2️⃣ JPA를 사용하더라도 지연로딩을 걸고, 필요할 때 fetch join을 사용하니, 이렇게 id만 가지도 있더라도 필요할 때 join으로 가져오면 될 것 같다고 판단했습니다! 그런데.. 객체지향적으로 코드를 작성하기 위해 JPA를 사용하는데.. id로 관계를 맺는 것이 과연 맞는 선택인지가 고민이 됩니다.! |
||
|
||
@Column(name = "user_id", nullable = false) | ||
private Long userId; | ||
|
||
@Enumerated(value = EnumType.STRING) | ||
@Column(name = "status", nullable = false, columnDefinition = "varchar(20)") | ||
private BookingStatus status; | ||
|
||
@Column(name = "party_size") | ||
private Integer partySize; | ||
|
||
public static Booking of(Long userId, Integer partySize, BookingSlot slot) { | ||
return new Booking( | ||
null, | ||
slot.getId(), | ||
userId, | ||
slot.book(), | ||
partySize | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
package com.nowait.booking.domain.model; | ||
|
||
import com.nowait.common.domain.model.BaseTimeEntity; | ||
import jakarta.persistence.Column; | ||
import jakarta.persistence.Entity; | ||
import jakarta.persistence.GeneratedValue; | ||
import jakarta.persistence.GenerationType; | ||
import jakarta.persistence.Id; | ||
import jakarta.persistence.Table; | ||
import java.time.LocalDate; | ||
import java.time.LocalTime; | ||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Entity | ||
@Table(name = "booking_slot") | ||
@Getter | ||
@AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
public class BookingSlot extends BaseTimeEntity { | ||
|
||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
@Column(name = "id") | ||
private Long id; | ||
|
||
@Column(name = "place_id", nullable = false) | ||
private Long placeId; | ||
|
||
@Column(name = "table_id", nullable = false) | ||
private Long tableId; | ||
|
||
@Column(name = "date", nullable = false) | ||
private LocalDate date; | ||
|
||
@Column(name = "time", nullable = false) | ||
private LocalTime time; | ||
|
||
@Column(name = "is_booked") | ||
private boolean isBooked; | ||
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋🏻♀️ 사실 이 BookingSlot의 id를 가지고 있는 Booking이 있는지 확인하면 예약이 되었는지 알 수 있지만, 이렇게 컬럼을 따로 두면 성능면에서 더 좋을 것 같아서 반정규화를 했습니다! 그런데 처음부터 반정규화를 하는 것보다 데이터 중복을 최소로 하고, 성능 문제가 발생할 때 반정규화를 하는 것이 더 나을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 개인적으로 연관관계를 최소화하는 것이 더 바람직하다고 생각하는 입장이긴합니다. :-) 데이터가 필요할 때, 디비에서 데이터를 가져오는 것이 장기적으로는 더 바람직한 방식이 아닐까 하는 생각이 듭니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확실히 'BookingSlot의 id를 가지고 있는 Booking이 있더라도, 결제 기한이 지났으면 예약할 수 있다.' 와 같이 요구사항이 변경되면.. 변경 포인트가 2개라 더 유지보수 하기 어려울 것 같네요..!🤔 |
||
|
||
@Column(name = "deposit_required") | ||
private boolean depositRequired; | ||
|
||
@Column(name = "confirm_required") | ||
private boolean confirmRequired; | ||
|
||
@Column(name = "deposit_policy_id") | ||
private Long deposit_policy_id; | ||
|
||
public BookingStatus book() { | ||
if (isBooked) { | ||
throw new IllegalArgumentException("이미 예약된 테이블입니다."); | ||
} | ||
|
||
isBooked = true; | ||
|
||
return isDepositRequired() ? BookingStatus.PENDING_PAYMENT | ||
: isConfirmRequired() ? BookingStatus.PENDING_CONFIRM : BookingStatus.CONFIRMED; | ||
} | ||
Comment on lines
+53
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋🏻♀️ 이 메서드가 내부적으로 상태도 바꾸면서, 반환값을 가지는 것이 클라이언트 입장에서 혼란스러울까요..? 메서드가 행위를 하는 것인지, 조회를 하는 것인지 명확하게 분리를 하는 것이 디버깅에도 좋다고 들어서요..! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 말씀하신대로 메소드가 어떤 동작을 할 것인지 명확하게 하는 것이 좋은데, 이런 구조에서는 쉽지 않을 것 같아요. 이 얘기는 저 위에 비지니스 로직을 누가 가질 것인지와 연관지에서 멘토링 시간에 얘기해보면 좋을 것 같습니다. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package com.nowait.booking.domain.model; | ||
|
||
import lombok.Getter; | ||
import lombok.RequiredArgsConstructor; | ||
|
||
@Getter | ||
@RequiredArgsConstructor | ||
public enum BookingStatus { | ||
CONFIRMED("확정됨"), | ||
PENDING_CONFIRM("확정 대기 중"), | ||
PENDING_PAYMENT("결재 대기 중"), | ||
CANCELLED("취소됨"); | ||
|
||
private final String description; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
package com.nowait.booking.domain.repository; | ||
|
||
import com.nowait.booking.domain.model.Booking; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface BookingRepository extends JpaRepository<Booking, Long> { | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package com.nowait.booking.domain.repository; | ||
|
||
import com.nowait.booking.domain.model.BookingSlot; | ||
import java.time.LocalDate; | ||
import java.time.LocalTime; | ||
import java.util.List; | ||
import java.util.Optional; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface BookingSlotRepository extends JpaRepository<BookingSlot, Long> { | ||
|
||
List<BookingSlot> findAllByPlaceIdAndDate(Long placeId, LocalDate date); | ||
|
||
Optional<BookingSlot> findFirstByPlaceIdAndDateAndTimeAndIsBookedFalse(Long placeId, | ||
LocalDate date, LocalTime time); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,21 @@ | ||
package com.nowait.booking.dto.response; | ||
|
||
import com.nowait.booking.domain.model.Booking; | ||
import com.nowait.booking.domain.model.BookingSlot; | ||
|
||
public record BookingRes( | ||
Long bookingId, | ||
String bookingStatus, | ||
boolean depositRequired, | ||
boolean confirmRequired | ||
) { | ||
|
||
public static BookingRes of(Booking booking, BookingSlot slot) { | ||
return new BookingRes( | ||
booking.getId(), | ||
booking.getStatus().getDescription(), | ||
slot.isDepositRequired(), | ||
slot.isConfirmRequired() | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package com.nowait.common.config; | ||
|
||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; | ||
|
||
@Configuration | ||
@EnableJpaAuditing | ||
public class JpaConfig { | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
package com.nowait.common.domain.model; | ||
|
||
import jakarta.persistence.Column; | ||
import jakarta.persistence.EntityListeners; | ||
import jakarta.persistence.MappedSuperclass; | ||
import java.time.LocalDateTime; | ||
import org.springframework.data.annotation.CreatedDate; | ||
import org.springframework.data.annotation.LastModifiedDate; | ||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; | ||
|
||
@MappedSuperclass | ||
@EntityListeners(AuditingEntityListener.class) | ||
public class BaseTimeEntity { | ||
|
||
@CreatedDate | ||
@Column(name = "created_at", nullable = false, updatable = false) | ||
private LocalDateTime createdAt; | ||
|
||
@LastModifiedDate | ||
@Column(name = "updated_at", nullable = false) | ||
private LocalDateTime updatedAt; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package com.nowait.common.event; | ||
|
||
public record BookedEvent( | ||
Long bookingId, | ||
Long placeId, | ||
Long userId | ||
) { | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
음 ... 이 코드는 페어가 필요한 것 같은데, 일단 리스너는 아직 구현하지 않으신건가요? 아니면, 제가 놓친 코드가 있을까요? 그리고 그것과 별개로, ApplicationEventPublisher를 써서 하고 싶은 것이 뭐였을지 궁금하네요!!!! 😬
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
예약 시나리오에서 예약이 완료되면, 사용자와 가게에 알림을 보내는 로직이 필요해서 넣었습니다! 그런데 아직 알림 기능을 개발하지 못해서 리스너는 구현이 되어 있지 않은 상태입니다😂 미리 코멘트를 남기거나, 필요할 때 작성하는게 더 나았을 것 같네요ㅜㅜ