-
Notifications
You must be signed in to change notification settings - Fork 0
[FEATURE]: 가게 심야 영업 허용 및 예약-브레이크타임 충돌 방어 로직 구현 #128
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
Changes from all commits
469804f
d466535
a652b8f
dedb64f
205a8d9
d1bf0ae
60f43a5
6ef6448
16932bc
5662d1e
d290dbd
adabe6d
828a408
aa58fc7
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 |
|---|---|---|
|
|
@@ -78,4 +78,28 @@ List<Booking> findActiveBookingsByTableAndDate( | |
| * @return 만료된 예약 리스트 | ||
| */ | ||
| List<Booking> findAllByStatusAndCreatedAtBefore(BookingStatus status, LocalDateTime threshold); | ||
|
|
||
|
|
||
| /** | ||
| * 특정 식당의 브레이크 타임과 겹치는 가장 늦은 예약 날짜를 조회합니다. | ||
| * @param adjustedBreakStart 브레이크 시작 시간에서 식당의 예약 간격(bookingIntervalMinutes)을 뺀 시간 | ||
| */ | ||
| @Query("select max(b.bookingDate) from Booking b " + | ||
| "where b.store.id = :storeId " + | ||
| "and b.status IN (com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.CONFIRMED, com.eatsfine.eatsfine.domain.booking.enums.BookingStatus.PENDING) " + | ||
|
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. 🧹 Nitpick | 🔵 Trivial 상태 필터에서 FQCN 열거형 사용이 같은 파일 내 다른 쿼리들과 일관성이 없습니다. 같은 파일 내의 다른 쿼리들(Line 28, 35, 43, 58, 67)은 모두 FQCN 방식이 타입 안전성 면에서 더 나은 접근이지만, 파일 내 일관성을 위해 한쪽으로 통일하는 것을 권장합니다. 만약 FQCN 방식을 채택한다면 다른 쿼리들도 함께 마이그레이션하는 것이 좋겠습니다. 🤖 Prompt for AI Agents |
||
| "and b.bookingDate >= CURRENT_DATE " + | ||
| "and (" + | ||
| " (b.bookingTime >= :breakStart and b.bookingTime < :breakEnd) " + // 1. 브레이크 타임 내 시작 | ||
| " OR " + | ||
| " (:adjustedBreakStart < :breakStart and b.bookingTime >= :adjustedBreakStart and b.bookingTime < :breakStart) " + // 2. 일반적인 경우 (낮) | ||
| " OR " + | ||
| " (:adjustedBreakStart > :breakStart and (b.bookingTime >= :adjustedBreakStart or b.bookingTime < :breakStart)) " + // 3. 자정 넘어가는 경우 (밤) | ||
| ")" | ||
| ) | ||
| Optional<LocalDate> findLastConflictingDate( | ||
| @Param("storeId") Long storeId, | ||
| @Param("breakStart") LocalTime breakStart, | ||
| @Param("breakEnd") LocalTime breakEnd, | ||
| @Param("adjustedBreakStart") LocalTime adjustedBreakStart | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| package com.eatsfine.eatsfine.domain.businesshours.dto; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonFormat; | ||
| import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| import lombok.Builder; | ||
|
|
||
| import java.time.DayOfWeek; | ||
| import java.time.LocalDate; | ||
| import java.time.LocalTime; | ||
| import java.util.List; | ||
|
|
||
|
|
@@ -38,6 +40,9 @@ public record UpdateBreakTimeDto( | |
| LocalTime breakStartTime, | ||
|
|
||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") | ||
| LocalTime breakEndTime | ||
| LocalTime breakEndTime, | ||
|
|
||
| @JsonIgnore | ||
| LocalDate effectiveDate | ||
|
Comment on lines
+45
to
+46
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. 🧹 Nitpick | 🔵 Trivial 응답 DTO에
현재 규모에서는 실용적인 접근이지만, 향후 필드가 늘어나면 분리를 고려해 주세요. 🤖 Prompt for AI Agents |
||
| ){} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import lombok.*; | ||
|
|
||
| import java.time.DayOfWeek; | ||
| import java.time.LocalDate; | ||
| import java.time.LocalTime; | ||
|
|
||
| @Entity | ||
|
|
@@ -39,25 +40,66 @@ public class BusinessHours extends BaseEntity { | |
| @Column(name = "break_end_time") | ||
| private LocalTime breakEndTime; | ||
|
|
||
| @Column(name = "new_break_start_time") | ||
| private LocalTime newBreakStartTime; | ||
|
|
||
| @Column(name = "new_break_end_time") | ||
| private LocalTime newBreakEndTime; | ||
|
|
||
| @Column(name = "effective_date") | ||
| private LocalDate effectiveDate; | ||
|
|
||
| // 휴일 여부 (특정 요일 고정 휴무) | ||
| @Builder.Default | ||
| @Column(name = "is_closed", nullable = false) | ||
| private boolean isClosed = false; | ||
|
|
||
| public void assignStore(Store store){ | ||
| public void assignStore(Store store) { | ||
| this.store = store; | ||
| } | ||
|
|
||
| // 영업시간 변경 | ||
| public void update(LocalTime open, LocalTime close, boolean isClosed){ | ||
| public void update(LocalTime open, LocalTime close, boolean isClosed) { | ||
| this.openTime = open; | ||
| this.closeTime = close; | ||
| this.isClosed = isClosed; | ||
| } | ||
|
|
||
| // 브레이크타임 변경 | ||
| public void updateBreakTime(LocalTime breakStart, LocalTime breakEnd){ | ||
| this.breakStartTime = breakStart; | ||
| this.breakEndTime = breakEnd; | ||
| public void updateBreakTime(LocalTime startTime, LocalTime endTime, LocalDate effectiveDate) { | ||
| // 오늘(혹은 과거) 날짜라면 -> 즉시 반영 | ||
| if (effectiveDate == null || !effectiveDate.isAfter(LocalDate.now())) { | ||
| this.breakStartTime = startTime; | ||
| this.breakEndTime = endTime; | ||
| // 대기 중인 데이터 초기화 | ||
| this.newBreakStartTime = null; | ||
| this.newBreakEndTime = null; | ||
| this.effectiveDate = null; | ||
| } | ||
| // 미래 날짜라면 -> 대기열에 저장 | ||
| else { | ||
| this.newBreakStartTime = startTime; | ||
| this.newBreakEndTime = endTime; | ||
| this.effectiveDate = effectiveDate; | ||
| } | ||
| } | ||
|
Comment on lines
+69
to
+85
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. 🧹 Nitpick | 🔵 Trivial
엔티티 내부에서 ♻️ 파라미터로 기준 날짜를 전달하는 방식 제안- public void updateBreakTime(LocalTime startTime, LocalTime endTime, LocalDate effectiveDate) {
- // 오늘(혹은 과거) 날짜라면 -> 즉시 반영
- if (effectiveDate == null || !effectiveDate.isAfter(LocalDate.now())) {
+ public void updateBreakTime(LocalTime startTime, LocalTime endTime, LocalDate effectiveDate, LocalDate today) {
+ // 오늘(혹은 과거) 날짜라면 -> 즉시 반영
+ if (effectiveDate == null || !effectiveDate.isAfter(today)) {🤖 Prompt for AI Agents |
||
|
|
||
| // 대기열 -> 실제 반영 (스케줄러 호출) | ||
| public void applyPendingBreakTime() { | ||
| if (this.newBreakStartTime != null && this.newBreakEndTime != null) { | ||
| this.breakStartTime = newBreakStartTime; | ||
| this.breakEndTime = newBreakEndTime; | ||
|
|
||
| // 반영 후 대기열 비우기 | ||
| this.newBreakStartTime = null; | ||
| this.newBreakEndTime = null; | ||
| this.effectiveDate = null; | ||
| } | ||
| } | ||
|
|
||
| public void clearPendingBreakTime() { | ||
| this.newBreakStartTime = null; | ||
| this.newBreakEndTime = null; | ||
| this.effectiveDate = null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| package com.eatsfine.eatsfine.domain.businesshours.service; | ||
|
|
||
| import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; | ||
| import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; | ||
| import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; | ||
| import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; | ||
| import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; | ||
| import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; | ||
| import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; | ||
| import com.eatsfine.eatsfine.domain.businesshours.validator.BreakTimeValidator; | ||
| import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; | ||
| import com.eatsfine.eatsfine.domain.store.entity.Store; | ||
|
|
@@ -12,16 +15,23 @@ | |
| import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; | ||
| import com.eatsfine.eatsfine.domain.store.validator.StoreValidator; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.LocalTime; | ||
| import java.util.Optional; | ||
|
|
||
| @Service | ||
| @Transactional | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class BusinessHoursCommandServiceImpl implements BusinessHoursCommandService { | ||
|
|
||
| private final StoreRepository storeRepository; | ||
| private final StoreValidator storeValidator; | ||
| private final BookingRepository bookingRepository; | ||
|
|
||
| @Override | ||
| public BusinessHoursResDto.UpdateBusinessHoursDto updateBusinessHours( | ||
|
|
@@ -59,17 +69,52 @@ public BusinessHoursResDto.UpdateBreakTimeDto updateBreakTime( | |
|
|
||
| Store store = storeValidator.validateStoreOwner(storeId, email); | ||
|
|
||
|
|
||
| for(BusinessHours bh : store.getBusinessHours()) { | ||
| if(bh.isClosed()) continue; | ||
| BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime()); | ||
| try { | ||
| BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime()); | ||
| } catch (BusinessHoursException e) { | ||
| log.error("브레이크 타임 검증 실패 - 요일: {}, 영업시간: {}~{}, 브레이크: {}~{}", | ||
| bh.getDayOfWeek(), bh.getOpenTime(), bh.getCloseTime(), | ||
| dto.breakStartTime(), dto.breakEndTime()); | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| // 브레이크 타임 해제 요청인 경우 (두 시간 모두 null) | ||
| if (dto.breakStartTime() == null && dto.breakEndTime() == null) { | ||
| for(BusinessHours bh : store.getBusinessHours()) { | ||
| bh.updateBreakTime(null, null, LocalDate.now()); | ||
| } | ||
| return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, null); | ||
|
|
||
| } | ||
|
|
||
| // 한쪽만 null인 비정상 요청 방어 | ||
| if (dto.breakStartTime() == null || dto.breakEndTime() == null) { | ||
| throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); | ||
| } | ||
|
Comment on lines
73
to
+97
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. 🧹 Nitpick | 🔵 Trivial null 체크가 검증 루프 이후에 위치하여 불필요한 반복이 발생합니다.
♻️ 순서 변경 제안 Store store = storeValidator.validateStoreOwner(storeId, email);
+ // 브레이크 타임 해제 요청인 경우 (두 시간 모두 null)
+ if (dto.breakStartTime() == null && dto.breakEndTime() == null) {
+ for(BusinessHours bh : store.getBusinessHours()) {
+ bh.updateBreakTime(null, null, LocalDate.now());
+ }
+ return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, null);
+ }
+
+ // 한쪽만 null인 비정상 요청 방어
+ if (dto.breakStartTime() == null || dto.breakEndTime() == null) {
+ throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME);
+ }
+
for(BusinessHours bh : store.getBusinessHours()) {
if(bh.isClosed()) continue;
try {
BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime());
} catch (BusinessHoursException e) {
log.error("브레이크 타임 검증 실패 - 요일: {}, 영업시간: {}~{}, 브레이크: {}~{}",
bh.getDayOfWeek(), bh.getOpenTime(), bh.getCloseTime(),
dto.breakStartTime(), dto.breakEndTime());
throw e;
}
}
- // 브레이크 타임 해제 요청인 경우 (두 시간 모두 null)
- if (dto.breakStartTime() == null && dto.breakEndTime() == null) {
- for(BusinessHours bh : store.getBusinessHours()) {
- bh.updateBreakTime(null, null, LocalDate.now());
- }
- return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, null);
- }
-
- // 한쪽만 null인 비정상 요청 방어
- if (dto.breakStartTime() == null || dto.breakEndTime() == null) {
- throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME);
- }
-
LocalTime adjustedBreakStart = dto.breakStartTime().minusMinutes(store.getBookingIntervalMinutes());As per coding guidelines, "불필요한 DB 쿼리 호출, N+1 문제 가능성이 있는지 확인." 🤖 Prompt for AI Agents |
||
|
|
||
| LocalTime adjustedBreakStart = dto.breakStartTime().minusMinutes(store.getBookingIntervalMinutes()); | ||
|
|
||
| // 1. 예약 충돌 확인 | ||
| Optional<LocalDate> lastConflictDate = bookingRepository.findLastConflictingDate( | ||
| storeId, dto.breakStartTime(), dto.breakEndTime(), adjustedBreakStart | ||
| ); | ||
|
Comment on lines
+99
to
+104
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.
래핑 발생 여부를 감지하는 방어 코드를 추가하세요: 🛡️ 방어 코드 예시 LocalTime adjustedBreakStart = dto.breakStartTime().minusMinutes(store.getBookingIntervalMinutes());
+ // adjustedBreakStart가 breakStart보다 크면 자정 래핑 발생 → Case B 무효화
+ if (adjustedBreakStart.isAfter(dto.breakStartTime())) {
+ adjustedBreakStart = LocalTime.MIDNIGHT; // 또는 Case B를 건너뛰도록 breakStart와 동일하게 설정
+ }🤖 Prompt for AI Agents |
||
| LocalDate effectiveDate; | ||
|
|
||
| effectiveDate = lastConflictDate.map( | ||
| localDate -> localDate.plusDays(1)) // 예약이 있으면 그 다음날 부터 | ||
| .orElseGet(LocalDate::now); // 예약 없으면 오늘부터 | ||
|
|
||
|
|
||
| store.getBusinessHours().forEach(s -> { | ||
| if(!s.isClosed()) { | ||
| s.updateBreakTime(dto.breakStartTime(), dto.breakEndTime()); | ||
| s.updateBreakTime(dto.breakStartTime(), dto.breakEndTime(), effectiveDate); | ||
| } | ||
| }); | ||
|
|
||
| return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto); | ||
| return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto, effectiveDate); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.eatsfine.eatsfine.domain.businesshours.service; | ||
|
|
||
| import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; | ||
| import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; | ||
| import com.eatsfine.eatsfine.domain.businesshours.repository.BusinessHoursRepository; | ||
| import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.transaction.support.TransactionTemplate; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class BusinessHoursScheduler { | ||
|
|
||
| private final BusinessHoursRepository businessHoursRepository; | ||
| private final TransactionTemplate transactionTemplate; | ||
|
|
||
| @Scheduled(cron = "0 0 0 * * *") | ||
| public void applyPendingBreakTimes() { | ||
| log.info("[Scheduler] 브레이크 타임 지연 반영 작업 시작"); | ||
|
|
||
| List<BusinessHours> pendingList = businessHoursRepository.findAllByEffectiveDateLessThanEqualAndEffectiveDateIsNotNull(LocalDate.now()); | ||
|
|
||
| int successCount = 0; | ||
| int failCount = 0; | ||
| int warnCount = 0; // 데이터 불일치(XOR) 건수 | ||
|
|
||
| // 전체 대상 건수 로그 | ||
| log.info("[Scheduler] 처리 대상 건수: {}건", pendingList.size()); | ||
|
|
||
| for (BusinessHours bh : pendingList) { | ||
| try { | ||
| Boolean isApplied = transactionTemplate.execute(status -> processEachPendingTime(bh.getId())); | ||
| if(Boolean.TRUE.equals(isApplied)) { | ||
| successCount++; | ||
| } else { | ||
| warnCount++; // XOR 등으로 인해 초기화만 된 경우 | ||
| } | ||
| } catch (Exception e) { | ||
| failCount++; | ||
| // 개별 건 처리 중 에러 발생 시 로그 남기고 다음 건 진행 | ||
| log.error("[Scheduler Exception] 반영 실패 - BH ID: {}", bh.getId(), e); | ||
| } | ||
| } | ||
| log.info("[Scheduler] 반영 작업 완료. (성공: {}/{} 건)", successCount, pendingList.size()); | ||
|
Comment on lines
+31
to
+52
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.
Line 52의 최종 로그에서 🔧 로그 개선 제안- log.info("[Scheduler] 반영 작업 완료. (성공: {}/{} 건)", successCount, pendingList.size());
+ log.info("[Scheduler] 반영 작업 완료. (성공: {}건, 실패: {}건, 데이터 불일치: {}건 / 총 {}건)", successCount, failCount, warnCount, pendingList.size());As per coding guidelines, "예외가 적절히 처리되었는지 확인해줘. (try-catch, throws, ExceptionAdvice)" 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| public boolean processEachPendingTime(Long bhId) { | ||
|
|
||
| BusinessHours bh = businessHoursRepository.findById(bhId) | ||
| .orElseThrow(() -> new BusinessHoursException(BusinessHoursErrorStatus._BUSINESS_HOURS_NOT_FOUND)); | ||
|
|
||
| if((bh.getNewBreakStartTime() == null) ^ (bh.getNewBreakEndTime() == null)) { | ||
| log.warn("[XOR Error] ID: {}", bh.getId()); | ||
| bh.clearPendingBreakTime(); | ||
| return false; // 초기화만 한 경우 false 리턴 | ||
| } | ||
| bh.applyPendingBreakTime(); | ||
| return true; // 정상 반영한 경우 true 리턴 | ||
| } | ||
| } | ||
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.
🧹 Nitpick | 🔵 Trivial
Javadoc에
adjustedBreakStart설명이 추가되었으나, 다른 파라미터 문서가 누락되어 있습니다.adjustedBreakStart에 대한 설명이 추가된 것은 좋습니다. 다만breakStart,breakEnd,storeId파라미터에 대한@param문서도 함께 기술하면 메서드 시그니처의 전체적인 이해가 쉬워집니다. 특히breakStart와breakEnd가 자정 교차 가능 여부에 대한 제약 조건도 명시하면 좋겠습니다.🤖 Prompt for AI Agents