Skip to content

Commit

Permalink
[BE] 최소 시간을 보장하는 추천 로직 추가 (#404)
Browse files Browse the repository at this point in the history
* refactor: 코드 스타일 및 컨벤션 적용

- 접근 제어자 수정
- 메서드 순서 컨벤션 적용
- 메서드 최대 길이 컨벤션 적용
- 불필요한 공백 제거

* feat: 최소 시간 보장 정렬 기준 추가

* feat: 잘못된 최소 시간 입력시 예외 발생 로직 추가

* feat: 최소 시간 보장 추천 로직 추가

* refactor: 추천 요청 파라미터 값 객체화 및 최소 시간 검증 로직 추가

* fix(ScheduleRecommendRequest): minTime 을 입력 받지 않은 경우 0으로 초기화

* refactor: 최소 시간 기준 수정 및 불필요한 정렬조건 삭제

* refactor: 가독성 있는 분기문 수정

* refactor: 일반적으로 사용되는 메서드 명명법 수정
  • Loading branch information
ikjo39 authored Nov 6, 2024
1 parent 23c88c0 commit f41b097
Show file tree
Hide file tree
Showing 16 changed files with 254 additions and 68 deletions.
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
) {
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
) {
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
);
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

0 comments on commit f41b097

Please sign in to comment.