Skip to content
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

[BE] 최소 시간을 보장하는 추천 로직 추가 #404

Merged
merged 9 commits into from
Nov 6, 2024
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
package kr.momo.controller.schedule;

import jakarta.validation.Valid;
import java.util.List;
import kr.momo.controller.MomoApiResponse;
import kr.momo.controller.auth.AuthAttendee;
import kr.momo.service.schedule.ScheduleService;
import kr.momo.service.schedule.dto.AttendeeScheduleResponse;
import kr.momo.service.schedule.dto.RecommendedSchedulesResponse;
import kr.momo.service.schedule.dto.ScheduleCreateRequest;
import kr.momo.service.schedule.dto.ScheduleRecommendRequest;
import kr.momo.service.schedule.dto.SchedulesResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
Expand Down Expand Up @@ -52,10 +52,10 @@ public MomoApiResponse<AttendeeScheduleResponse> findMySchedule(@PathVariable St

@GetMapping("/api/v1/meetings/{uuid}/recommended-schedules")
public MomoApiResponse<RecommendedSchedulesResponse> recommendSchedules(
@PathVariable String uuid, @RequestParam String recommendType, @RequestParam List<String> attendeeNames
@PathVariable String uuid, @ModelAttribute @Valid ScheduleRecommendRequest request
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기본값 설정👍

RecommendedSchedulesResponse response = scheduleService.recommendSchedules(
uuid, recommendType, attendeeNames
uuid, request.recommendType(), request.attendeeNames(), request.minTime()
);
return new MomoApiResponse<>(response);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import kr.momo.controller.MomoApiResponse;
import kr.momo.controller.annotation.ApiErrorResponse;
import kr.momo.controller.annotation.ApiSuccessResponse;
import kr.momo.controller.auth.AuthAttendee;
import kr.momo.service.schedule.dto.AttendeeScheduleResponse;
import kr.momo.service.schedule.dto.RecommendedSchedulesResponse;
import kr.momo.service.schedule.dto.ScheduleCreateRequest;
import kr.momo.service.schedule.dto.ScheduleRecommendRequest;
import kr.momo.service.schedule.dto.SchedulesResponse;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "Schedule", description = "일정 API")
public interface ScheduleControllerDocs {
Expand Down Expand Up @@ -90,17 +90,14 @@ MomoApiResponse<AttendeeScheduleResponse> findMySchedule(
추천 기준에 따라 이른 시간 순 혹은 길게 볼 수 있는 순으로 추천합니다.
- earliest: 이른 시간 순
- longTerm: 길게 볼 수 있는 순

추천 연산에 사용할 참여자 이름을 명시하여 필터링할 수 있습니다.<br>
약속 내의 모든 참여자가 전달된 경우 일부 참여자들이 참여할 수 있는 일정을 함께 추천하며,<br>
이외의 경우 전달된 참여자들이 모두 참여할 수 있는 일정이 추천됩니다.
""")
@ApiSuccessResponse.Ok("추천 일정 조회 성공")
MomoApiResponse<RecommendedSchedulesResponse> recommendSchedules(
@PathVariable @Schema(description = "약속 UUID") String uuid,
@RequestParam @Schema(description = "추천 기준(이른 시간 순 / 길게 볼 수 있는 순)", example = "earliest")
String recommendType,
@RequestParam @Schema(description = "추천 대상 참여자 이름", example = "페드로, 재즈, 모모")
List<String> attendeeNames
@ModelAttribute @Valid ScheduleRecommendRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
import java.time.LocalDateTime;
import java.util.Objects;

public record DateInterval(
LocalDate startDate,
LocalDate endDate
) implements RecommendInterval {
public record DateInterval(LocalDate startDate, LocalDate endDate) implements RecommendInterval {

@Override
public boolean isSequential(RecommendInterval nextInterval) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
import java.time.Duration;
import java.time.LocalDateTime;

public record DateTimeInterval(
LocalDateTime startDateTime,
LocalDateTime endDateTime
) implements RecommendInterval {
public record DateTimeInterval(LocalDateTime startDateTime, LocalDateTime endDateTime) implements RecommendInterval {

@Override
public boolean isSequential(RecommendInterval nextInterval) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
void deleteByAttendee(Attendee attendee);

@Query("""
SELECT
new kr.momo.domain.schedule.DateAndTimeslot(ad.date, s.timeslot)
FROM Schedule s
JOIN s.availableDate ad
WHERE s.attendee IN :essentialAttendees
GROUP BY ad.date, s.timeslot
HAVING COUNT(s.attendee.id) = :#{#essentialAttendees.size()}
ORDER BY ad.date ASC, s.timeslot ASC
SELECT
new kr.momo.domain.schedule.DateAndTimeslot(ad.date, s.timeslot)
FROM Schedule s
JOIN s.availableDate ad
WHERE s.attendee IN :essentialAttendees
GROUP BY ad.date, s.timeslot
HAVING COUNT(s.attendee.id) = :#{#essentialAttendees.size()}
ORDER BY ad.date ASC, s.timeslot ASC
""")
List<DateAndTimeslot> findAllDateAndTimeslotByEssentialAttendees(
@Param("essentialAttendees") List<Attendee> essentialAttendees
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
import kr.momo.domain.schedule.DateTimeInterval;
import kr.momo.domain.schedule.RecommendInterval;

public record CandidateSchedule(
RecommendInterval dateTimeInterval, AttendeeGroup attendeeGroup
) {
public record CandidateSchedule(RecommendInterval dateTimeInterval, AttendeeGroup attendeeGroup) {

public static CandidateSchedule of(
LocalDateTime startDateTime, LocalDateTime endDateTime, AttendeeGroup attendeeGroup
Expand All @@ -22,7 +20,8 @@ public static CandidateSchedule of(

public static List<CandidateSchedule> mergeContinuous(
List<CandidateSchedule> sortedSchedules,
BiPredicate<CandidateSchedule, CandidateSchedule> isContinuous
BiPredicate<CandidateSchedule, CandidateSchedule> isContinuous,
int minSize
) {
List<CandidateSchedule> mergedSchedules = new ArrayList<>();
int idx = 0;
Expand All @@ -32,12 +31,20 @@ public static List<CandidateSchedule> mergeContinuous(
.takeWhile(i -> i == headIdx || isSequential(i, sortedSchedules, isContinuous))
.map(sortedSchedules::get)
.toList();
addIfLongerThanOrEqualToMinTime(subList, mergedSchedules, minSize);
idx += subList.size();
}
return mergedSchedules;
}

private static void addIfLongerThanOrEqualToMinTime(
List<CandidateSchedule> subList, List<CandidateSchedule> mergedSchedules, int minSize
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
List<CandidateSchedule> subList, List<CandidateSchedule> mergedSchedules, int minSize
private static void addIfLongerThanOrEqualToMinTime(

일반적으로 사용되는 메서드 명명법을 따라 볼까요? 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 이 부분은 흥미롭네요.
일반적으로 사용되는 메서드 명명법을 참고할 만한 곳이 있으면 공유 요청 드려도 될까요?
추후 메서드 명명에 도움이 될 것 같습니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음.. 별도로 레퍼런스가 있다기보다는 보통 영문에서 lt, lte, gt, gte 와 같은 표기를 많이 사용해서 그게 자연스럽다고 느꼈던 것 같아요.

자바 계열에서는 JUnit의 검증 메서드 명명법을 참고하기는 합니다. 저 메서드 보자마자 JUnit의 greaterThanOrEqualTo() 가 떠올랐어요ㅋㅋㅋ

) {
if (minSize <= subList.size()) {
subList.stream()
.reduce(CandidateSchedule::merge)
.ifPresent(mergedSchedules::add);
idx += subList.size();
}
return mergedSchedules;
}

private static boolean isSequential(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
public enum ScheduleErrorCode implements ErrorCodeType {

INVALID_SCHEDULE_TIMESLOT(HttpStatus.BAD_REQUEST, "해당 시간을 선택할 수 없습니다. 주최자가 설정한 시간만 선택 가능합니다."),
INVALID_SCHEDULE_RECOMMEND_TYPE(HttpStatus.BAD_REQUEST, "해당 추천 기준이 없습니다.");
INVALID_SCHEDULE_RECOMMEND_TYPE(HttpStatus.BAD_REQUEST, "해당 추천 기준이 없습니다."),
INVALID_MIN_TIME(HttpStatus.BAD_REQUEST, "최소 시간 입력이 잘못되었습니다.")
;

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ public AttendeeScheduleResponse findMySchedule(String uuid, long attendeeId) {
}

@Transactional(readOnly = true)
public RecommendedSchedulesResponse recommendSchedules(String uuid, String recommendType, List<String> names) {
public RecommendedSchedulesResponse recommendSchedules(
String uuid, String recommendType, List<String> names, int minimumTime
) {
Meeting meeting = meetingRepository.findByUuid(uuid)
.orElseThrow(() -> new MomoException(MeetingErrorCode.NOT_FOUND_MEETING));
AttendeeGroup attendeeGroup = new AttendeeGroup(attendeeRepository.findAllByMeeting(meeting));
Expand All @@ -131,11 +133,13 @@ public RecommendedSchedulesResponse recommendSchedules(String uuid, String recom
ScheduleRecommender recommender = scheduleRecommenderFactory.getRecommenderOf(
attendeeGroup, filteredGroup
);
List<CandidateSchedule> recommendedResult = recommender.recommend(filteredGroup, recommendType,
meeting.getType());
List<CandidateSchedule> recommendedResult = recommender.recommend(
filteredGroup, recommendType, meeting.getType(), minimumTime
);

List<RecommendedScheduleResponse> scheduleResponses = RecommendedScheduleResponse.fromCandidateSchedules(
recommendedResult);
recommendedResult
);
Comment on lines +141 to +142
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다온이 만든 코드는 아니지만 😅
지금 dto 변환 작업을 두 번에 나눠서 하고 있는데 한 번에 하면 더 좋을 것 같아요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 작업은 리팩토링 이슈로 분리하여 다뤄봐도 좋을 것 같네요

return RecommendedSchedulesResponse.of(meeting.getType(), scheduleResponses);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.momo.service.schedule.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;

@Schema(description = "일정 추천 요청")
public record ScheduleRecommendRequest(

@NotEmpty
@Schema(description = "추천 기준(이른 시간 순 / 길게 볼 수 있는 순)", example = "earliest")
String recommendType,

@NotEmpty
@Schema(description = "추천 대상 참여자 이름", example = "페드로, 재즈, 모모")
List<String> attendeeNames,

@Schema(description = "최소 만남 시간(시간 단위)", example = "0, 1, 2, 3")
@Min(value = 0, message = "최소 시간은 0보다 작을 수 없습니다.")
Integer minTime
) {

public ScheduleRecommendRequest {
if (minTime == null) {
minTime = 0;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ public FilteredScheduleRecommender(ScheduleRepository scheduleRepository) {
}

@Override
protected List<CandidateSchedule> extractProperSortedDiscreteScheduleOf(AttendeeGroup filteredGroup,
MeetingType type) {
protected List<CandidateSchedule> extractProperSortedDiscreteScheduleOf(
AttendeeGroup filteredGroup, MeetingType type
) {
return findAllScheduleAvailableByEveryAttendee(filteredGroup);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,37 @@
@RequiredArgsConstructor
public abstract class ScheduleRecommender {

private static final int ONE_HOUR_TIME_INTERVAL_SIZE = 2;

protected final ScheduleRepository scheduleRepository;

public List<CandidateSchedule> recommend(AttendeeGroup group, String recommendType, MeetingType meetingType) {
List<CandidateSchedule> mergedCandidateSchedules = calcCandidateSchedules(group, meetingType);
public List<CandidateSchedule> recommend(
AttendeeGroup group, String recommendType, MeetingType meetingType, int minTime
) {
int minSize = minTime * ONE_HOUR_TIME_INTERVAL_SIZE;
List<CandidateSchedule> mergedCandidateSchedules = calcCandidateSchedules(group, meetingType, minSize);
sortSchedules(mergedCandidateSchedules, recommendType);
return mergedCandidateSchedules.stream()
.limit(getMaxRecommendCount())
.toList();
}

private List<CandidateSchedule> calcCandidateSchedules(AttendeeGroup group, MeetingType type) {
private List<CandidateSchedule> calcCandidateSchedules(AttendeeGroup group, MeetingType type, int minSize) {
List<CandidateSchedule> intersectedDateTimes = extractProperSortedDiscreteScheduleOf(group, type);
return CandidateSchedule.mergeContinuous(intersectedDateTimes, this::isContinuous);
return CandidateSchedule.mergeContinuous(intersectedDateTimes, this::isContinuous, minSize);
}

abstract List<CandidateSchedule> extractProperSortedDiscreteScheduleOf(AttendeeGroup group, MeetingType type);

abstract boolean isContinuous(CandidateSchedule current, CandidateSchedule next);

private void sortSchedules(List<CandidateSchedule> mergedCandidateSchedules, String recommendType) {
RecommendedScheduleSortStandard sortStandard = RecommendedScheduleSortStandard.from(recommendType);
CandidateScheduleSorter sorter = sortStandard.getSorter();
sorter.sort(mergedCandidateSchedules);
}

abstract long getMaxRecommendCount();
protected abstract List<CandidateSchedule> extractProperSortedDiscreteScheduleOf(
AttendeeGroup group, MeetingType type
);

protected abstract boolean isContinuous(CandidateSchedule current, CandidateSchedule next);

protected abstract long getMaxRecommendCount();
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ protected boolean isContinuous(CandidateSchedule current, CandidateSchedule next
}

@Override
long getMaxRecommendCount() {
protected long getMaxRecommendCount() {
return MAXIMUM_RECOMMEND_COUNT;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,42 @@ void findMySchedule() {
void recommendSchedules() {
RestAssured.given().log().all()
.pathParam("uuid", meeting.getUuid())
.queryParams("recommendType", EARLIEST_ORDER.getType(), "attendeeNames", attendee.name())
.queryParam("recommendType", EARLIEST_ORDER.getType())
.queryParams("attendeeNames", List.of(attendee.name()))
.queryParam("minTime", 0)
.contentType(ContentType.JSON)
.when().get("/api/v1/meetings/{uuid}/recommended-schedules")
.then().log().all()
.statusCode(HttpStatus.OK.value());
}

@DisplayName("추천 약속을 조회시 최소 시간을 입력받지 않아도 동작한다.")
@Test
void recommendSchedulesWithoutMinTime() {
RestAssured.given().log().all()
.pathParam("uuid", meeting.getUuid())
.queryParam("recommendType", EARLIEST_ORDER.getType())
.queryParams("attendeeNames", List.of(attendee.name()))
.contentType(ContentType.JSON)
.when().get("/api/v1/meetings/{uuid}/recommended-schedules")
.then().log().all()
.statusCode(HttpStatus.OK.value());
}

@DisplayName("추천 약속 조회시 최소 시간이 0보다 작으면 예외가 발생한다.")
@Test
void recommendSchedulesIfSmallerThanZero() {
RestAssured.given().log().all()
.pathParam("uuid", meeting.getUuid())
.queryParam("recommendType", EARLIEST_ORDER.getType())
.queryParams("attendeeNames", List.of(attendee.name()))
.queryParam("minTime", -1)
.contentType(ContentType.JSON)
.when().get("/api/v1/meetings/{uuid}/recommended-schedules")
.then().log().all()
.statusCode(HttpStatus.BAD_REQUEST.value());
}

private void createAttendeeSchedule(Attendee attendee) {
List<Schedule> schedules = new ArrayList<>();
schedules.add(new Schedule(attendee, today, Timeslot.TIME_0300));
Expand Down
Loading