diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/controller/command/DateCommandController.java b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/command/DateCommandController.java index d3fab30..758fc22 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/controller/command/DateCommandController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/command/DateCommandController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.namul.api.payload.response.DefaultResponse; import org.springframework.web.bind.annotation.*; @@ -10,17 +11,16 @@ import org.withtime.be.withtimebe.domain.date.dto.request.DateRequestDTO; import org.withtime.be.withtimebe.domain.date.dto.response.DateResponseDTO; import org.withtime.be.withtimebe.domain.date.entity.DateCourseBookmark; -import org.withtime.be.withtimebe.domain.date.entity.DatePlace; import org.withtime.be.withtimebe.domain.date.service.command.DateCommandService; +import org.withtime.be.withtimebe.domain.date.service.command.dto.RecommendedCourseResult; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/date-courses") +@Tag(name = "데이트 생성 API") public class DateCommandController { private final DateCommandService dateCommandService; @@ -32,10 +32,9 @@ public class DateCommandController { @PostMapping("/") public DefaultResponse createDateCourse( @RequestBody DateRequestDTO.CreateDateCourse request -// @AuthenticatedMember Member member ){ - List datePlaces = dateCommandService.createDateCourse(request); - DateResponseDTO.DateCourse dateCourse = DateConverter.createDateCourseInfo(datePlaces); + RecommendedCourseResult datePlaces = dateCommandService.createDateCourse(request); + DateResponseDTO.DateCourse dateCourse = DateConverter.createDateCourseInfo(datePlaces.places(), datePlaces.signature()); return DefaultResponse.created(dateCourse); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/controller/query/DateQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/query/DateQueryController.java index 14195d5..a5e5d4a 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/controller/query/DateQueryController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/query/DateQueryController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.namul.api.payload.response.DefaultResponse; import org.springframework.data.domain.Page; @@ -21,6 +22,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/date-courses") +@Tag(name = "데이트 조회 API") public class DateQueryController { private final DateQueryService dateQueryService; diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/converter/DateConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/date/converter/DateConverter.java index 338b946..a124b8b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/converter/DateConverter.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/converter/DateConverter.java @@ -40,19 +40,23 @@ public static DateCourse createDateCourse(DateRequestDTO.SaveDateCourse dateCour // 나중에 생성한 정보를 리턴하는 데 사용,,? 근데 애초에 그 뭐야 // builder()로 만들 때 잘 만들어주면 안되냐 // List -> DateResponseDTO.DateCourseInfo - public static DateResponseDTO.DateCourse createDateCourseInfo(List datePlaces){ + // 단일 추천 코스를 응답으로 구성(시그니처 포함) + public static DateResponseDTO.DateCourse createDateCourseInfo(List datePlaces, String signature){ List datePlaceDtos = datePlaces.stream() .map(DateConverter::createDatePlace) .toList(); return DateResponseDTO.DateCourse.builder() .name(LocalDateTime.now().toLocalDate().toString()) + .datePlaces(datePlaceDtos) + .signature(signature) // ← 추가 .build(); } // DatePlace -> DateResponseDTO.DatePlace public static DateResponseDTO.DatePlace createDatePlace(DatePlace datePlace) { return DateResponseDTO.DatePlace.builder() + .datePlaceId(datePlace.getId()) .name(datePlace.getName()) .image(datePlace.getImage()) .tel(datePlace.getTel()) @@ -75,7 +79,6 @@ public static DateResponseDTO.DateCourse createDateCourse(DateCourse dateCourse) .toList(); return DateResponseDTO.DateCourse.builder() - .dateCourseId(dateCourse.getId()) .name(dateCourse.getName()) .datePlaces(datePlaces) .build(); diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/dto/request/DateRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/request/DateRequestDTO.java index 901d734..94d2e2a 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/dto/request/DateRequestDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/request/DateRequestDTO.java @@ -1,8 +1,11 @@ package org.withtime.be.withtimebe.domain.date.dto.request; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import org.withtime.be.withtimebe.domain.date.entity.enums.DatePriceRange; import org.withtime.be.withtimebe.domain.date.entity.enums.DateTime; @@ -11,37 +14,50 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; public record DateRequestDTO() { public record CreateDateCourse( - @NotNull(message = "예산을 선택해주세요") - DatePriceRange budget, + @NotNull(message = "예산을 선택해주세요") + @Schema(example = "UNDER_10K") + DatePriceRange budget, - @Size(min = 1, message = "최소 하나 이상의 값을 선택해주세요") - @NotNull(message = "값이 비어있을 수 없습니다") - List datePlaces, + @Size(min = 1, message = "최소 하나 이상의 값을 선택해주세요") + @NotNull(message = "값이 비어있을 수 없습니다") + @Schema(example = "[\"서울 종로구\"]") + List datePlaces, - @NotNull(message = "데이트 시간을 선택해주세요") - DateTime dateDurationTime, + @NotNull(message = "데이트 시간을 선택해주세요") + @Schema(example = "ONETOTWO") + DateTime dateDurationTime, - List mealPlan, + @Schema(example = "[\"BREAKFAST\"]") + List mealPlan, - @NotBlank(message = "이동 수단을 선택해주세요") - Transportation transportation, + @NotNull(message = "이동 수단을 선택해주세요") + @Schema(example = "WALK") + Transportation transportation, - @Size(min = 1, max = 3) - @NotNull(message = "사용자 취향을 선택해주세요") - List userPreferredKeywords, + @Size(min = 1, max = 3) + @NotNull(message = "사용자 취향을 선택해주세요") + @Schema(example = "[\"레트로 골목\", \"카페\"]") + List userPreferredKeywords, - @JsonFormat(shape = JsonFormat.Shape.STRING, - pattern = "yyyy-MM-dd'T'HH:mm:ss" - ) - LocalDateTime startTime, + @NotBlank(message = "startTime은 필수입니다") + @Pattern( + regexp = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$", + message = "startTime 형식은 yyyy-MM-dd'T'HH:mm 이어야 합니다" + ) + @JsonProperty("startTime") + @Schema(example = "2025-08-14T07:45") + LocalDateTime startTime, - int attemptCount - ){} + // 이미 보여줬던 코스의 시그니처(예: "12-45-33") + @Schema(example = "[]") + Set excludedCourseSignatures + ) {} public record SaveDateCourse( List datePlaceIds, diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/dto/response/DateResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/response/DateResponseDTO.java index c25f1dc..e09fc07 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/dto/response/DateResponseDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/response/DateResponseDTO.java @@ -14,6 +14,7 @@ public record DateCourseBookmark( @Builder public record DatePlace( + Long datePlaceId, String name, String image, String tel, @@ -28,9 +29,9 @@ public record DatePlace( @Builder public record DateCourse( - Long dateCourseId, String name, - List datePlaces + List datePlaces, + String signature ){} @Builder diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDateCourse.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDateCourse.java index bcbfb9b..118794a 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDateCourse.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDateCourse.java @@ -1,18 +1,32 @@ +// ScheduledDateCourse package org.withtime.be.withtimebe.domain.date.entity.model; import lombok.Builder; import lombok.Getter; +import java.util.Comparator; import java.util.List; +import java.util.Objects; @Builder @Getter public class ScheduledDateCourse implements Comparable { - private List scheduledDatePlaces; - private double weight; + + private final List scheduledDatePlaces; + private final double weight; // 코스 총점 @Override public int compareTo(ScheduledDateCourse o) { - return (int)(this.weight - o.weight); + return Double.compare(this.weight, o.weight); + } + + public static final Comparator BY_WEIGHT_ASC = + Comparator.comparingDouble(ScheduledDateCourse::getWeight); + public static final Comparator BY_WEIGHT_DESC = + BY_WEIGHT_ASC.reversed(); + + public ScheduledDateCourse(List scheduledDatePlaces, double weight) { + this.scheduledDatePlaces = Objects.requireNonNull(scheduledDatePlaces, "scheduledDatePlaces must not be null"); + this.weight = weight; } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDatePlace.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDatePlace.java index 55b88dd..b20592f 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDatePlace.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDatePlace.java @@ -1,30 +1,63 @@ +// ScheduledDatePlace package org.withtime.be.withtimebe.domain.date.entity.model; import lombok.Builder; import lombok.Getter; +import lombok.ToString; import org.withtime.be.withtimebe.domain.date.entity.DatePlace; +import org.withtime.be.withtimebe.domain.date.entity.enums.PlaceType; import java.time.Duration; -import java.time.LocalTime; +import java.time.LocalDateTime; +import java.util.Objects; @Getter +@ToString public class ScheduledDatePlace { - private DatePlace datePlace; - private LocalTime startTime; - private LocalTime endTime; - private double score; - // 그리고 여기서 그냥 comparator 쓰면 되지 않나? + private final DatePlace datePlace; - @Builder - public ScheduledDatePlace(DatePlace datePlace, LocalTime startTime, Duration duration, double score) { - this.datePlace = datePlace; + // 시간은 점수 산정 단계에선 없어도 됨(선택) + private final LocalDateTime startTime; // nullable + private final LocalDateTime endTime; // nullable + + private final double score; + + @Builder(toBuilder = true) + private ScheduledDatePlace(DatePlace datePlace, + LocalDateTime startTime, + LocalDateTime endTime, + double score) { + this.datePlace = Objects.requireNonNull(datePlace, "datePlace must not be null"); this.startTime = startTime; - this.endTime = startTime.plus(duration); + this.endTime = endTime; this.score = score; } + // 시간 없이 점수만 설정 + public static ScheduledDatePlace ofScoreOnly(DatePlace place, double score) { + return ScheduledDatePlace.builder() + .datePlace(place) + .score(score) + .build(); + } + + // 시작시각 주면 placeType duration으로 종료시각 계산 + public ScheduledDatePlace withScheduleFrom(LocalDateTime start) { + PlaceType type = datePlace.getPlaceType(); + Duration dur = (type != null) ? type.getDuration() : Duration.ZERO; + LocalDateTime end = (start != null) ? start.plus(dur) : null; + return this.toBuilder() + .startTime(start) + .endTime(end) + .build(); + } + public Duration getDuration() { - return Duration.between(startTime, endTime); + if (startTime != null && endTime != null) { + return Duration.between(startTime, endTime); + } + PlaceType type = datePlace.getPlaceType(); + return (type != null) ? type.getDuration() : Duration.ZERO; } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandService.java index 01b9cd5..091ee45 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandService.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandService.java @@ -3,15 +3,13 @@ import org.withtime.be.withtimebe.domain.date.dto.request.DateRequestDTO; import org.withtime.be.withtimebe.domain.date.entity.DateCourse; import org.withtime.be.withtimebe.domain.date.entity.DateCourseBookmark; -import org.withtime.be.withtimebe.domain.date.entity.DatePlace; +import org.withtime.be.withtimebe.domain.date.service.command.dto.RecommendedCourseResult; import org.withtime.be.withtimebe.domain.member.entity.Member; -import java.util.List; - public interface DateCommandService { - public DateCourseBookmark createDateCourseBookmark(Long dateCourseId, Member member); - public DateCourse deleteDateCourseBookmark(Long dateCourseId, Member member); - public DateCourseBookmark createDateCourseBookmarkWithGeneratedCourse(DateRequestDTO.SaveDateCourse request, Member members); - public List createDateCourse(DateRequestDTO.CreateDateCourse request); + DateCourseBookmark createDateCourseBookmark(Long dateCourseId, Member member); + DateCourse deleteDateCourseBookmark(Long dateCourseId, Member member); + DateCourseBookmark createDateCourseBookmarkWithGeneratedCourse(DateRequestDTO.SaveDateCourse request, Member members); + RecommendedCourseResult createDateCourse(DateRequestDTO.CreateDateCourse request); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandServiceImpl.java index c8c72a1..b5e463f 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandServiceImpl.java @@ -18,16 +18,14 @@ import org.withtime.be.withtimebe.domain.date.repository.DateCourseBookmarkRepository; import org.withtime.be.withtimebe.domain.date.repository.DateCourseRepository; import org.withtime.be.withtimebe.domain.date.repository.DatePlaceRepository; +import org.withtime.be.withtimebe.domain.date.service.command.dto.RecommendedCourseResult; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.global.error.code.DateCourseErrorCode; import org.withtime.be.withtimebe.global.error.exception.DateCourseException; -import java.time.Duration; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; @RequiredArgsConstructor @@ -39,173 +37,192 @@ public class DateCommandServiceImpl implements DateCommandService{ private final DateCourseRepository dateCourseRepository; private final DatePlaceRepository datePlaceRepository; - // 사용자 맞춤형 데이트 코스 생성 - public List createDateCourse( - DateRequestDTO.CreateDateCourse request - ) { - // 데이트 장소 장소로 필터링 - List datePlaces = new ArrayList<>(); - for (String datePlace : request.datePlaces()) { - String[] dateKeywords = datePlace.split(" "); - String placeKeyword1 = dateKeywords[0]; - String placeKeyword2 = dateKeywords[1]; - datePlaces = datePlaceRepository.findByAddressContainingAll(placeKeyword1, placeKeyword2); + /** 컨트롤러로 전달할 단일 추천 결과 (코스 + 중복 방지 시그니처) */ + + + @Transactional(readOnly = true) + /** 단일 코스 생성 (저장/북마크/attemptCount 없음, excludedCourseSignatures로 중복 제외) */ + public RecommendedCourseResult createDateCourse(DateRequestDTO.CreateDateCourse request) { + if (request == null || request.dateDurationTime() == null) { + return new RecommendedCourseResult(List.of(), null); } - int placeCountByTime = request.dateDurationTime().getValue(); - Optional mealType = MealType.getMealTypeByTime(request.startTime()); - List scheduledDateCourses = switch (request.budget()) { - case UNDER_10K -> - // 패턴 만들어줌 - getScheduledDateCourses( - request, mealType, placeCountByTime, datePlaces, BudgetLevel.FREE, request.mealPlan()); - case FROM_10K_TO_20K -> getScheduledDateCourses( - request, mealType, placeCountByTime, datePlaces, BudgetLevel.LOW, request.mealPlan()); - case FROM_20K_TO_30K -> getScheduledDateCourses( - request, mealType, placeCountByTime, datePlaces, BudgetLevel.MEDIUM, request.mealPlan()); - case OVER_30K -> getScheduledDateCourses( - request, mealType, placeCountByTime, datePlaces, BudgetLevel.HIGH, request.mealPlan()); + + // 1) 후보 장소 수집 (주소 토큰 기반) + ID 기준 중복 제거(순서 보존) + List candidates = collectCandidatesByAddressTokens(request.datePlaces()); + if (candidates.isEmpty()) return new RecommendedCourseResult(List.of(), null); + + // 2) 코스 길이/식사/예산 레벨 산정 + int courseCount = request.dateDurationTime().getValue(); + Optional mealType = (request.startTime() == null) + ? Optional.empty() + : MealType.getMealTypeByTime(request.startTime()); + + BudgetLevel budgetLevel = switch (request.budget()) { + case UNDER_10K -> BudgetLevel.FREE; + case FROM_10K_TO_20K -> BudgetLevel.LOW; + case FROM_20K_TO_30K -> BudgetLevel.MEDIUM; + case OVER_30K -> BudgetLevel.HIGH; + default -> BudgetLevel.LOW; }; - List> courses = scheduledDateCourses.stream() - .sorted() - .map(dateCourse -> dateCourse.getScheduledDatePlaces().stream() - .map(ScheduledDatePlace::getDatePlace).toList()) - .limit(4) - .toList(); + // 3) place_type 패턴 결정 (정책 반영) + List pattern = buildPattern(mealType, request.startTime(), courseCount, request.mealPlan(), budgetLevel); - if (request.attemptCount() == 3){ - DateCourse dateCourse = DateCourse.builder().build(); - for (List datePlacesTop3 : courses) { - List datePlaceDateCourses = datePlacesTop3.stream() - .map(datePlace -> DatePlaceDateCourse.builder() - .datePlace(datePlace).build()) - .toList(); - assignScheduleTimes(datePlaceDateCourses, request.startTime()); - dateCourse.addDatePlaceDateCourses(datePlaceDateCourses); - dateCourseRepository.save(dateCourse); - } - } - return !courses.isEmpty() ? courses.get(request.attemptCount()) : new ArrayList<>(); - } + // 4) 스코어링 (사용자/예산 키워드 일치 시 +1) + Set budgetKw = KeywordForBudget.getKeywordsByBudget(budgetLevel).stream() + .map(KeywordForBudget::getLabel) + .collect(Collectors.toSet()); + Set userKw = (request.userPreferredKeywords() == null) + ? Collections.emptySet() + : new HashSet<>(request.userPreferredKeywords()); + + List scored = scorePlacesDto(candidates, userKw, budgetKw); - private void assignScheduleTimes(List datePlaceDateCourse, LocalDateTime startedAt){ - LocalDateTime cursor = startedAt; + // (선택) 타입별 상위 N만 남겨 조합 폭발 방지 — 필요시 주석 해제 + // final int TOP_N = 10; + // Map> byType = scored.stream() + // .collect(Collectors.groupingBy(sp -> sp.getDatePlace().getPlaceType())); + // List trimmed = byType.values().stream() + // .flatMap(list -> list.stream().limit(TOP_N)) + // .toList(); + // List combos = new ArrayList<>(generateCombination(pattern, trimmed)); - for (DatePlaceDateCourse placeDateCourse : datePlaceDateCourse) { - placeDateCourse.setStartTime(cursor); - PlaceType placeType = placeDateCourse.getDatePlace().getPlaceType(); - Duration duration = placeType.getDuration(); + // 5) 모든 조합 생성 → 동점 무작위화 후 점수 내림차순 정렬 + List combos = new ArrayList<>(generateCombination(pattern, scored)); + if (combos.isEmpty()) return new RecommendedCourseResult(List.of(), null); + Collections.shuffle(combos); // 동점 집합 무작위 섞기 + combos.sort(ScheduledDateCourse.BY_WEIGHT_DESC); - LocalDateTime end = cursor.plus(duration); - placeDateCourse.setEndTime(end); + // 6) 이미 본 코스(시그니처) 제외하고 첫 코스 반환 + Set excluded = (request.excludedCourseSignatures() == null) + ? Collections.emptySet() + : new HashSet<>(request.excludedCourseSignatures()); - cursor = end; + for (ScheduledDateCourse c : combos) { + String sig = courseSignature(c.getScheduledDatePlaces()); + if (!excluded.contains(sig)) { + List places = c.getScheduledDatePlaces().stream() + .map(ScheduledDatePlace::getDatePlace) + .toList(); + return new RecommendedCourseResult(places, sig); + } } - } - private List getScheduledDateCourses( - DateRequestDTO.CreateDateCourse request, Optional mealType, - int placeCountByTime, List datePlaces, BudgetLevel budgetLevel, List mealPlan - ) { - List patterns = buildPattern(mealType, request.startTime(), placeCountByTime, mealPlan); - // 예산 키워드 - List keywordsForBudget = KeywordForBudget.getKeywordsByBudget(budgetLevel).stream() - .map(KeywordForBudget::getLabel) + // 전부 이미 본 코스면, 최고점 코스라도 반환 + ScheduledDateCourse top = combos.get(0); + String sig = courseSignature(top.getScheduledDatePlaces()); + List places = top.getScheduledDatePlaces().stream() + .map(ScheduledDatePlace::getDatePlace) .toList(); - // 사용자 맞춤 & 예산 키워드 맞춤 장소 가중치 계산 - List scheduledDatePlaces = scorePlacesDto(datePlaces, request.userPreferredKeywords(), keywordsForBudget); - // 위에 있는 패턴과 장소로 조합을 만들어서 코스 반환(조합) - return generateCombination(patterns, scheduledDatePlaces); - } - - // 데이트코스 북마크 생성 - 직접 데이트 코스 찾아보기 - public DateCourseBookmark createDateCourseBookmark(Long dateCourseId, Member member) { - DateCourse dateCourse = dateCourseRepository.findById(dateCourseId) - .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourse_NOT_FOUND)); - DateCourseBookmark dateCourseBookmark = DateConverter.createDateCourseBookmark(dateCourse, member); - return dateCourseBookmarkRepository.save(dateCourseBookmark); + return new RecommendedCourseResult(places, sig); } - // 데이트코스 북마크 삭제 - public DateCourse deleteDateCourseBookmark(Long dateCourseId, Member member){ - DateCourse dateCourse = dateCourseRepository.findById(dateCourseId) - .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourse_NOT_FOUND)); - DateCourseBookmark dateCourseBookmark = dateCourseBookmarkRepository.findByMemberAndDateCourse(member, dateCourse) - .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourseBookMark_NOT_FOUND)); - dateCourseBookmarkRepository.delete(dateCourseBookmark); - return dateCourse; - } + // ───────────────────────── 내부 로직 ───────────────────────── - // 데이트코스 북마크 생성 - AI 기반 데이트 코스 만들기 - public DateCourseBookmark createDateCourseBookmarkWithGeneratedCourse( - DateRequestDTO.SaveDateCourse request, - Member member - ){ - DateCourse dateCourse = DateConverter.createDateCourse(request); - List datePlaces = datePlaceRepository.findAllById(request.datePlaceIds()); - List datePlaceDateCourses = datePlaces.stream() - .map(datePlace -> DatePlaceDateCourse.builder().datePlace(datePlace).build()) - .toList(); - dateCourse.addDatePlaceDateCourses(datePlaceDateCourses); - dateCourseRepository.save(dateCourse); + /** 주소 키워드 토큰으로 후보 조회 + ID 기준 중복 제거(순서 보존) */ + private List collectCandidatesByAddressTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) return List.of(); - DateCourseBookmark dateCourseBookmark = DateConverter.createDateCourseBookmark(dateCourse, member); - return dateCourseBookmarkRepository.save(dateCourseBookmark); - } + Map byId = new LinkedHashMap<>(); + // id 없는 엔티티 중복 방지(레퍼런스 기준) + Set noIdSet = new LinkedHashSet<>(); - private List buildPattern(Optional mealType, LocalDateTime startTime - , int placeCountByTime, List mealPlan) { - List pattern = new ArrayList<>(); - while (pattern.size() < placeCountByTime) { - if (mealType.isPresent() && mealPlan.contains(mealType.get())) { - pattern.add(PlaceType.TIME_EAT); - } else { - if (pattern.size() + 2 <= placeCountByTime) { - pattern.add(PlaceType.TIME_SEE); - pattern.add(PlaceType.TIME_CAFE); + for (String token : tokens) { + if (token == null || token.isBlank()) continue; + String[] parts = token.trim().split("\\s+"); + String k1 = parts.length >= 1 ? parts[0] : ""; + String k2 = parts.length >= 2 ? parts[1] : ""; + List found = datePlaceRepository.findByAddressContainingAll(k1, k2); + for (DatePlace p : found) { + Long id = p.getId(); + if (id != null) { + byId.putIfAbsent(id, p); } else { - pattern.add(PlaceType.TIME_SEE); + noIdSet.add(p); } } } + List result = new ArrayList<>(byId.values()); + result.addAll(noIdSet); + return result; + } + + /** 정책 기반 place_type 분배 (유효성 검사는 제외) */ + private List buildPattern( + Optional mealType, LocalDateTime startTime, + int courseCount, List mealPlan, BudgetLevel budgetLevel + ) { + int eat = 0, see = 0, cafe = 0; + switch (courseCount) { + case 2 -> { see = 1; cafe = 1; } + case 3 -> { eat = 1; see = 1; cafe = 1; } + case 4 -> { eat = 1; see = 2; cafe = 1; } + default -> { eat = 1; see = Math.max(1, courseCount - 2); cafe = 1; } + } + // 저예산 → 구경 위주 + if (budgetLevel == BudgetLevel.FREE || budgetLevel == BudgetLevel.LOW) { + eat = Math.min(eat, 1); + see = Math.max(1, courseCount - eat - 1); + cafe = courseCount - eat - see; + } + // 시작 시간의 식사타입이 mealPlan에 없으면 식사 제외 + if (mealType.isEmpty() || mealPlan == null || !mealPlan.contains(mealType.get())) { + eat = 0; + see = Math.max(1, courseCount - 1); + cafe = courseCount - see; + } + + List pattern = new ArrayList<>(); + if (eat > 0) { pattern.add(PlaceType.TIME_EAT); eat--; } + while (see > 0 || cafe > 0) { + if (see > 0) { pattern.add(PlaceType.TIME_SEE); see--; } + if (cafe > 0) { pattern.add(PlaceType.TIME_CAFE); cafe--; } + } return pattern; } + /** 점수 계산(사용자 + 예산 키워드 모두 +1) — null-safe */ private List scorePlacesDto( List datePlaces, - List userPreferredKeywords, - List budgetKeywords - ){ - return datePlaces.stream() - .map(place -> - { - List labels = place.getPlaceCategories().stream() - .map(dp -> dp.getPlaceCategory().getLabel()) + Set userPreferredKeywords, + Set budgetKeywords + ) { + List safePlaces = (datePlaces == null) ? List.of() : datePlaces; + Set userKws = (userPreferredKeywords == null) ? Set.of() : userPreferredKeywords; + Set budgetKws = (budgetKeywords == null) ? Set.of() : budgetKeywords; + + return safePlaces.stream() + .map(place -> { + List labels = + (place.getPlaceCategories() == null) ? List.of() + : place.getPlaceCategories().stream() + .map(dp -> dp.getPlaceCategory()) + .filter(Objects::nonNull) + .map(pc -> pc.getLabel()) + .filter(Objects::nonNull) .toList(); - double score = 0.0; + double score = 0.0; + if (!labels.isEmpty()) { + if (!budgetKws.isEmpty()) { for (String label : labels) { - if (budgetKeywords.contains(label)){ - score += 1.0; - } + if (budgetKws.contains(label)) score += 1.0; } - - for (String userPreferredKeyword : userPreferredKeywords) { - if (labels.contains(userPreferredKeyword)){ - score += 1.0; - } + } + if (!userKws.isEmpty()) { + for (String kw : userKws) { + if (labels.contains(kw)) score += 1.0; } + } + } - return ScheduledDatePlace.builder() - .datePlace(place) - .score(score) - .build(); - }) + return ScheduledDatePlace.ofScoreOnly(place, score); + }) .sorted(Comparator.comparingDouble(ScheduledDatePlace::getScore).reversed()) .toList(); } - // 조합 생성 + /** 패턴 순서에 맞춰 모든 조합 생성 */ private List generateCombination(List pattern, List datePlaces){ List> result = new ArrayList<>(); generateByRecur(pattern, datePlaces, 0, new ArrayList<>(), result); @@ -237,11 +254,60 @@ private void generateByRecur( for (ScheduledDatePlace candidate : allCandidates) { if (candidate.getDatePlace().getPlaceType() != neededType) continue; - if (current.contains(candidate)) continue; + if (current.contains(candidate)) continue; // 동일 장소 중복 방지 current.add(candidate); generateByRecur(pattern, allCandidates, index + 1, current, result); current.remove(current.size() - 1); } } + + /** 코스 시그니처(장소ID 순서대로, ID 없으면 name 대체) */ + private String courseSignature(List list) { + return list.stream() + .map(s -> { + DatePlace p = s.getDatePlace(); + Long id = (p != null) ? p.getId() : null; + if (id != null) return String.valueOf(id); + String name = (p != null) ? p.getName() : "unknown"; + return name != null ? name : "unknown"; + }) + .collect(Collectors.joining("-")); + } + + + // 데이트코스 북마크 삭제 + public DateCourse deleteDateCourseBookmark(Long dateCourseId, Member member){ + DateCourse dateCourse = dateCourseRepository.findById(dateCourseId) + .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourse_NOT_FOUND)); + DateCourseBookmark dateCourseBookmark = dateCourseBookmarkRepository.findByMemberAndDateCourse(member, dateCourse) + .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourseBookMark_NOT_FOUND)); + dateCourseBookmarkRepository.delete(dateCourseBookmark); + return dateCourse; + } + + // 데이트코스 북마크 생성 - AI 기반 데이트 코스 만들기 + public DateCourseBookmark createDateCourseBookmarkWithGeneratedCourse( + DateRequestDTO.SaveDateCourse request, + Member member + ){ + DateCourse dateCourse = DateConverter.createDateCourse(request); + List datePlaces = datePlaceRepository.findAllById(request.datePlaceIds()); + List datePlaceDateCourses = datePlaces.stream() + .map(datePlace -> DatePlaceDateCourse.builder().datePlace(datePlace).build()) + .toList(); + dateCourse.addDatePlaceDateCourses(datePlaceDateCourses); + dateCourseRepository.save(dateCourse); + + DateCourseBookmark dateCourseBookmark = DateConverter.createDateCourseBookmark(dateCourse, member); + return dateCourseBookmarkRepository.save(dateCourseBookmark); + } + + // 데이트코스 북마크 생성 - 직접 데이트 코스 찾아보기 + public DateCourseBookmark createDateCourseBookmark(Long dateCourseId, Member member) { + DateCourse dateCourse = dateCourseRepository.findById(dateCourseId) + .orElseThrow(() -> new DateCourseException(DateCourseErrorCode.DateCourse_NOT_FOUND)); + DateCourseBookmark dateCourseBookmark = DateConverter.createDateCourseBookmark(dateCourse, member); + return dateCourseBookmarkRepository.save(dateCourseBookmark); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/dto/RecommendedCourseResult.java b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/dto/RecommendedCourseResult.java new file mode 100644 index 0000000..5558564 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/dto/RecommendedCourseResult.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.domain.date.service.command.dto; + +import org.withtime.be.withtimebe.domain.date.entity.DatePlace; + +import java.util.List; + +public record RecommendedCourseResult( + List places, // 최종 코스 장소들(순서 유지) + String signature // "장소ID-장소ID-..." (중복 방지용) +) {} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/global/common/HealthCheckController.java b/src/main/java/org/withtime/be/withtimebe/global/common/HealthCheckController.java index f645f77..7ac49ab 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/common/HealthCheckController.java +++ b/src/main/java/org/withtime/be/withtimebe/global/common/HealthCheckController.java @@ -1,15 +1,15 @@ package org.withtime.be.withtimebe.global.common; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.namul.api.payload.response.DefaultResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController +@Tag(name = "헬스 체킹 API") public class HealthCheckController { // @Operation(summary = "회원가입 API", description = "새로운 사용자를 추가합니다") diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index 6d0fce2..f5040bd 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -61,7 +61,8 @@ public class SecurityConfig { "/oauth2/authorization/**", "/swagger-ui/**", "/swagger-resources/**", - "/v3/api-docs/**" + "/v3/api-docs/**", + "/health" }; private RequestMatcher[] admin = {