diff --git a/build.gradle b/build.gradle index b943a5f..93202c5 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + //Querydsl 추가 + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" // OAuth2 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -84,4 +89,8 @@ tasks.named('test') { jar { enabled = false -} \ No newline at end of file +} + +clean { + delete file('src/main/generated') +} 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 new file mode 100644 index 0000000..d3fab30 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/command/DateCommandController.java @@ -0,0 +1,92 @@ +package org.withtime.be.withtimebe.domain.date.controller.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.date.converter.DateConverter; +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.member.entity.Member; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +import java.util.List; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/date-courses") +public class DateCommandController { + + private final DateCommandService dateCommandService; + + @Operation(summary = "사용자 맞춤형 데이트 코스 생성 API by 제인", description = "데이트 코스 생성 API입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다") + }) + @PostMapping("/") + public DefaultResponse createDateCourse( + @RequestBody DateRequestDTO.CreateDateCourse request +// @AuthenticatedMember Member member + ){ + List datePlaces = dateCommandService.createDateCourse(request); + DateResponseDTO.DateCourse dateCourse = DateConverter.createDateCourseInfo(datePlaces); + return DefaultResponse.created(dateCourse); + } + + @Operation(summary = "데이트 코스 북마크 생성 API by 제인", + description = "직접 데이트 코스를 찾을 때 사용하는 데이트 코스 북마크 API입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다"), + @ApiResponse(responseCode = "DATE_COURSE404_1", + description = "해당 코스를 찾을 수 없습니다") + }) + @PostMapping("/{dateCourseId}/bookmarks") + public DefaultResponse createDateCourseBookmark( + @PathVariable Long dateCourseId, + @AuthenticatedMember Member member + ){ + DateCourseBookmark dateCourseBookmark = dateCommandService.createDateCourseBookmark(dateCourseId, member); + DateResponseDTO.DateCourseBookmark responseDTO = DateConverter.createDateCourseBookmarkResponseDTO(dateCourseBookmark); + return DefaultResponse.created(responseDTO); + } + + @Operation(summary = "데이트 코스 북마크 삭제 API by 제인", + description = "데이트 코스 북마크 API입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다"), + @ApiResponse(responseCode = "DATE_COURSE404_1", + description = "해당 코스를 찾을 수 없습니다") + }) + @DeleteMapping("/{dateCourseId}/bookmarks") + public DefaultResponse deleteDateCourseBookmark( + @PathVariable Long dateCourseId, + @AuthenticatedMember Member member + ){ + dateCommandService.deleteDateCourseBookmark(dateCourseId, member); + return DefaultResponse.noContent(); + } + + @Operation(summary = "데이트 코스 북마크 생성 API by 제인", + description = "데이트 코스를 생성할 때 사용하는 데이트 코스 북마크 API입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다"), + @ApiResponse(responseCode = "DATE_COURSE404_1", + description = "해당 코스를 찾을 수 없습니다") + }) + @PostMapping("/bookmarks") + public DefaultResponse createDateCourseBookmarkWithGeneratedCourse( + @RequestBody DateRequestDTO.SaveDateCourse request, + @AuthenticatedMember Member member + ){ + DateCourseBookmark dateCourseBookmarkWithGeneratedCourse = dateCommandService.createDateCourseBookmarkWithGeneratedCourse(request, member); + DateResponseDTO.DateCourseBookmark dateCourseBookmarkResponseDTO = DateConverter.createDateCourseBookmarkResponseDTO(dateCourseBookmarkWithGeneratedCourse); + return DefaultResponse.created(dateCourseBookmarkResponseDTO); + } + +} 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 new file mode 100644 index 0000000..c8dd7fb --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/controller/query/DateQueryController.java @@ -0,0 +1,61 @@ +package org.withtime.be.withtimebe.domain.date.controller.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.date.converter.DateConverter; +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.DateCourse; +import org.withtime.be.withtimebe.domain.date.service.query.DateQueryService; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/date-courses") +public class DateQueryController { + + private final DateQueryService dateQueryService; + + @Operation(summary = "데이트 코스 리스트 조회 API by 제인", description = "데이트 코스 전체 조회 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "404", description = "DATE_COURSE404_1 : 해당하는 데이트 코스를 찾을 수 없습니다.") + }) + @SwaggerPageable + @PostMapping("/") + public DefaultResponse findDateCourses( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestBody DateRequestDTO.DateCourseSearchCond dateCourseSearchCond + ) { + Page dateCourses = dateQueryService.findDateCourses(dateCourseSearchCond, pageable); + DateResponseDTO.DateCourseList response = DateConverter.createDateCourseList(dateCourses); + return DefaultResponse.ok(response); + } + + @Operation(summary = "데이트 코스 북마크 리스트 조회 API by 제인", description = "데이트 코스 북마크 리스트 조회 API입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다"), + @ApiResponse(responseCode = "DATE_COURSE_BOOKMARK404_1", + description = "해당 코스를 찾을 수 없습니다") + }) + @SwaggerPageable + @PostMapping("/bookmarks") + public DefaultResponse findDateCourseBookmark( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestBody DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, + @AuthenticatedMember Member member + ){ + Page bookmarkedDateCourses = dateQueryService.findDateCourseBookmarks(dateCourseSearchCond, pageable, member); + DateResponseDTO.DateCourseList response = DateConverter.createDateCourseList(bookmarkedDateCourses); + return DefaultResponse.ok(response); + } +} 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 new file mode 100644 index 0000000..338b946 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/converter/DateConverter.java @@ -0,0 +1,99 @@ +package org.withtime.be.withtimebe.domain.date.converter; + +import org.springframework.data.domain.Page; +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.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.entity.DatePlaceDateCourse; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class DateConverter { + + // DateCourse, Member -> DateCourseBookmark 엔티티 생성 + public static DateCourseBookmark createDateCourseBookmark(DateCourse dateCourse, Member member) { + return DateCourseBookmark.builder() + .member(member) + .dateCourse(dateCourse) + .build(); + } + + // DateCourseBookmark -> DateCourseBookmark ResponseDTO 생성 + public static DateResponseDTO.DateCourseBookmark createDateCourseBookmarkResponseDTO(DateCourseBookmark dateCourseBookmark) { + return DateResponseDTO.DateCourseBookmark.builder() + .dateCourseId(dateCourseBookmark.getId()) + .build(); + } + + // DateRequestDTO.SaveDateCourse -> DateCourse + public static DateCourse createDateCourse(DateRequestDTO.SaveDateCourse dateCourse){ + return DateCourse.builder() + .name(dateCourse.name()) + .build(); + } + + // 나중에 생성한 정보를 리턴하는 데 사용,,? 근데 애초에 그 뭐야 + // builder()로 만들 때 잘 만들어주면 안되냐 + // List -> DateResponseDTO.DateCourseInfo + public static DateResponseDTO.DateCourse createDateCourseInfo(List datePlaces){ + List datePlaceDtos = datePlaces.stream() + .map(DateConverter::createDatePlace) + .toList(); + + return DateResponseDTO.DateCourse.builder() + .name(LocalDateTime.now().toLocalDate().toString()) + .build(); + } + + // DatePlace -> DateResponseDTO.DatePlace + public static DateResponseDTO.DatePlace createDatePlace(DatePlace datePlace) { + return DateResponseDTO.DatePlace.builder() + .name(datePlace.getName()) + .image(datePlace.getImage()) + .tel(datePlace.getTel()) + .averagePrice(datePlace.getAveragePrice()) + .information(datePlace.getInformation()) + .latitude(datePlace.getLatitude()) + .longitude(datePlace.getLongitude()) + .roadNameAddress(datePlace.getRoadNameAddress()) + .lotNumberAddress(datePlace.getLotNumberAddress()) + .placeType(datePlace.getPlaceType()) + .build(); + } + + // DateResponseDTO.DateCourse -> DateResponseDTO.DateCourse + public static DateResponseDTO.DateCourse createDateCourse(DateCourse dateCourse){ + + List datePlaces = dateCourse.getDatePlaceDateCourses().stream() + .map(DatePlaceDateCourse::getDatePlace) + .map(DateConverter::createDatePlace) + .toList(); + + return DateResponseDTO.DateCourse.builder() + .dateCourseId(dateCourse.getId()) + .name(dateCourse.getName()) + .datePlaces(datePlaces) + .build(); + } + + // Page -> DateRequestDTO.DateCourseList + public static DateResponseDTO.DateCourseList createDateCourseList(Page dateCourses){ + List dateCourseList = dateCourses.stream() + .map(DateConverter::createDateCourse) + .toList(); + + return DateResponseDTO.DateCourseList.builder() + .dateCourseList(dateCourseList) + .totalPages(dateCourses.getTotalPages()) + .currentPage(dateCourses.getNumber()) + .currentSize(dateCourses.getSize()) + .hasNextPage(dateCourses.hasNext()) + .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 new file mode 100644 index 0000000..901d734 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/request/DateRequestDTO.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.date.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +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; +import org.withtime.be.withtimebe.domain.date.entity.enums.MealType; +import org.withtime.be.withtimebe.domain.date.entity.enums.Transportation; + +import java.time.LocalDateTime; +import java.util.List; + +public record DateRequestDTO() { + + + public record CreateDateCourse( + @NotNull(message = "예산을 선택해주세요") + DatePriceRange budget, + + @Size(min = 1, message = "최소 하나 이상의 값을 선택해주세요") + @NotNull(message = "값이 비어있을 수 없습니다") + List datePlaces, + + @NotNull(message = "데이트 시간을 선택해주세요") + DateTime dateDurationTime, + + List mealPlan, + + @NotBlank(message = "이동 수단을 선택해주세요") + Transportation transportation, + + @Size(min = 1, max = 3) + @NotNull(message = "사용자 취향을 선택해주세요") + List userPreferredKeywords, + + @JsonFormat(shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd'T'HH:mm:ss" + ) + LocalDateTime startTime, + + int attemptCount + ){} + + public record SaveDateCourse( + List datePlaceIds, + String name + ){} + + public record DateCourseSearchCond( + DatePriceRange budget, + List datePlaces, + DateTime dateDurationTime, + List mealTypes, + Transportation transportation, + List userPreferredKeywords + ){} +} 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 new file mode 100644 index 0000000..c25f1dc --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/dto/response/DateResponseDTO.java @@ -0,0 +1,44 @@ +package org.withtime.be.withtimebe.domain.date.dto.response; + +import lombok.Builder; +import org.withtime.be.withtimebe.domain.date.entity.enums.PlaceType; + +import java.util.List; + +public record DateResponseDTO() { + + @Builder + public record DateCourseBookmark( + Long dateCourseId + ){} + + @Builder + public record DatePlace( + String name, + String image, + String tel, + Integer averagePrice, + String information, + double latitude, + double longitude, + String roadNameAddress, + String lotNumberAddress, + PlaceType placeType + ){} + + @Builder + public record DateCourse( + Long dateCourseId, + String name, + List datePlaces + ){} + + @Builder + public record DateCourseList( + List dateCourseList, + Integer totalPages, + Integer currentPage, + Integer currentSize, + Boolean hasNextPage + ){} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourse.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourse.java index 136536c..3460c40 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourse.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourse.java @@ -2,9 +2,16 @@ import jakarta.persistence.*; import lombok.*; +import org.withtime.be.withtimebe.domain.date.entity.enums.DatePriceRange; +import org.withtime.be.withtimebe.domain.date.entity.enums.DateTime; +import org.withtime.be.withtimebe.domain.date.entity.enums.MealType; +import org.withtime.be.withtimebe.domain.date.entity.enums.Transportation; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.global.common.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @@ -24,4 +31,45 @@ public class DateCourse extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + @Column(name = "date_price_range") + @Enumerated(EnumType.STRING) + private DatePriceRange datePriceRange; + + @ElementCollection + @CollectionTable(name ="date_places", joinColumns = + @JoinColumn(name= "date_course_id")) + @Builder.Default + List datePlaces= new ArrayList<>(); + + @Column(name = "dateTime") + @Enumerated(EnumType.STRING) + private DateTime dateTime; + + @ElementCollection + @CollectionTable(name = "course_meal_types", + joinColumns = @JoinColumn(name = "date_course_id")) + @Column(name = "meal_type") + @Enumerated(EnumType.STRING) + @Builder.Default + private List mealTypes= new ArrayList<>(); + + @Column(name = "transportation") + private Transportation transportation; + + @Builder.Default + @OneToMany(mappedBy = "dateCourse", cascade = CascadeType.ALL, orphanRemoval = true) + private List DateCoursePlaceCategory = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "dateCourse", cascade = CascadeType.ALL, orphanRemoval = true) + private List datePlaceDateCourses = new ArrayList<>(); + + // 연관 관계 맵핑 메소드 + public void addDatePlaceDateCourses(List datePlaceDateCourses) { + for (DatePlaceDateCourse datePlaceDateCourse : datePlaceDateCourses) { + datePlaceDateCourse.setDateCourse(this); + } + datePlaceDateCourses.addAll(datePlaceDateCourses); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourseBookmark.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourseBookmark.java new file mode 100644 index 0000000..0c25014 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCourseBookmark.java @@ -0,0 +1,26 @@ +package org.withtime.be.withtimebe.domain.date.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "date_course_bookmark") +public class DateCourseBookmark { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "date_course_bookmark_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "date_course_id", nullable = false) + private DateCourse dateCourse; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCoursePlaceCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCoursePlaceCategory.java new file mode 100644 index 0000000..262f436 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DateCoursePlaceCategory.java @@ -0,0 +1,32 @@ +package org.withtime.be.withtimebe.domain.date.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "date_course_place_category") +public class DateCoursePlaceCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "date_course_place_category_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "date_course_id", nullable = false) + private DateCourse dateCourse; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_category_id", nullable = false) + private PlaceCategory placeCategory; + + @Builder + private DateCoursePlaceCategory(DateCourse dateCourse, PlaceCategory placeCategory) { + this.dateCourse = dateCourse; + this.placeCategory = placeCategory; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java index 736aec3..707980b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlace.java @@ -6,6 +6,8 @@ import org.withtime.be.withtimebe.domain.date.entity.enums.PlaceType; import org.withtime.be.withtimebe.global.common.BaseEntity; +import java.util.List; + @Entity @Getter @Builder @@ -49,4 +51,7 @@ public class DatePlace extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "place_type") private PlaceType placeType; -} \ No newline at end of file + + @OneToMany(mappedBy = "datePlace", cascade = CascadeType.ALL) + private List placeCategories; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java index fbb5009..cc80509 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/DatePlaceDateCourse.java @@ -4,6 +4,8 @@ import lombok.*; import org.withtime.be.withtimebe.global.common.BaseEntity; +import java.time.LocalDateTime; + @Entity @Getter @Builder @@ -17,6 +19,7 @@ public class DatePlaceDateCourse extends BaseEntity { @Column(name = "date_place_date_course_id") private Long id; + @Setter @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "date_course_id", nullable = false) private DateCourse dateCourse; @@ -24,4 +27,12 @@ public class DatePlaceDateCourse extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "date_place_id", nullable = false) private DatePlace datePlace; + + @Setter + @Column(name = "start_time") + private LocalDateTime startTime; + + @Setter + @Column(name = "end_time") + private LocalDateTime endTime; } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/BudgetLevel.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/BudgetLevel.java new file mode 100644 index 0000000..e1b2378 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/BudgetLevel.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +public enum BudgetLevel { + FREE, + LOW, + MEDIUM, + HIGH +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DatePriceRange.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DatePriceRange.java new file mode 100644 index 0000000..fb9d3f8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DatePriceRange.java @@ -0,0 +1,27 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +import lombok.AllArgsConstructor; +import org.withtime.be.withtimebe.global.error.code.DateCourseErrorCode; +import org.withtime.be.withtimebe.global.error.exception.DateCourseException; + +@AllArgsConstructor +public enum DatePriceRange { + UNDER_10K("1만원 이하", 0, 10000), + FROM_10K_TO_20K("1~2만원", 10000, 20000), + FROM_20K_TO_30K("2~3만원", 20000, 30000), + OVER_30K("3만원", 30000, Integer.MAX_VALUE), + ; + + private final String label; + private final int minPrice; + private final int maxPrice; + + public static DatePriceRange fromPrice(int price){ + for (DatePriceRange range : values()){ + if (price >= range.minPrice && price < range.maxPrice){ + return range; + } + } + throw new DateCourseException(DateCourseErrorCode.DateCourse_INVALID_BUDGET_RANGE); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DateTime.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DateTime.java new file mode 100644 index 0000000..910a7a1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/DateTime.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.withtime.be.withtimebe.global.error.code.DateCourseErrorCode; +import org.withtime.be.withtimebe.global.error.exception.DateCourseException; + +@Getter +@AllArgsConstructor +public enum DateTime { + ONETOTWO(2), + THREETOFROUR(3), + HALFDAY(4), + ALLDAY(5); + + private final int value; +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/KeywordForBudget.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/KeywordForBudget.java new file mode 100644 index 0000000..7f088c2 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/KeywordForBudget.java @@ -0,0 +1,41 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.withtime.be.withtimebe.domain.weather.entity.Keyword; + +import java.util.Arrays; +import java.util.List; + +@Getter +@AllArgsConstructor +public enum KeywordForBudget { + RETRO_ALLEY("레트로 골목", BudgetLevel.FREE), + BOOK_CAFE("북카페/책방", BudgetLevel.FREE), + EXHIBITION("전시 공간", BudgetLevel.FREE), + WALK("산책 중심", BudgetLevel.FREE), + + KOREAN("한식", BudgetLevel.LOW), + BRUNCH_CAFE("브런치 카페", BudgetLevel.LOW), + DESSERT_CAFE("디저트 카페", BudgetLevel.LOW), + VIEW_SPOT("전망 좋은 곳", BudgetLevel.LOW), + + WESTERN("양식", BudgetLevel.MEDIUM), + ROOFTOP_CAFE("루프탑 카페", BudgetLevel.MEDIUM), + SENSUAL("감성적인", BudgetLevel.MEDIUM), + HANDCRAFT("수공예 체험 공간", BudgetLevel.MEDIUM), + + FUSION("퓨전 음식점", BudgetLevel.HIGH), + PUB("이자카야/펍", BudgetLevel.HIGH), + HYPER_SENSUAL("감각적인", BudgetLevel.HIGH); + + private final String label; + private final BudgetLevel budgetLevel; + + public static List getKeywordsByBudget(BudgetLevel budget) { + return Arrays.stream(KeywordForBudget.values()) + .filter(k -> k.getBudgetLevel().ordinal() <= budget.ordinal()) + .toList(); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/MealType.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/MealType.java new file mode 100644 index 0000000..7c93edf --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/MealType.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.Optional; + +@AllArgsConstructor +public enum MealType { + BREAKFAST(LocalTime.of(7, 0) , LocalTime.of(10, 0)), + LUNCH(LocalTime.of(11, 0) , LocalTime.of(14, 0)), + DINNER(LocalTime.of(17, 0) , LocalTime.of(20, 0)) + ; + + private final LocalTime startTime; + private final LocalTime endTime; + + // 식사 시간 유효성 확인 + public boolean isInMealTime(LocalDateTime time){ + LocalTime t = time.toLocalTime(); + return !t.isBefore(this.startTime) && !t.isAfter(this.endTime); + } + + // 시간으로 MealType 반환 + public static Optional getMealTypeByTime(LocalDateTime time){ + return Arrays.stream(MealType.values()) + .filter(m -> m.isInMealTime(time)) + .findFirst(); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceCategoryType.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceCategoryType.java index 0c9ec2e..9bb061d 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceCategoryType.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceCategoryType.java @@ -5,7 +5,7 @@ @Getter @AllArgsConstructor -public enum PlaceCategoryType { +public enum PlaceCategoryType { MOOD("분위기"), ACTIVITY_TYPE("활동량"), PLACE_STYLE("장소 스타일"), diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java index c01432c..8f77734 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/PlaceType.java @@ -3,14 +3,22 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.time.Duration; +import java.time.LocalTime; + @Getter @AllArgsConstructor public enum PlaceType { - TIME_EAT("식사", "식사 위주 장소"), - TIME_SEE("구경", "구경 위주 장소"), - TIME_CAFE("카페", "카페 위주 장소"), + TIME_EAT("식사", "식사 위주 장소", Duration.ofHours(1)), + TIME_SEE("구경", "구경 위주 장소", Duration.ofMinutes(30)), + TIME_CAFE("카페", "카페 위주 장소", Duration.ofMinutes(90)), ; private final String label; private final String description; + private final Duration duration; + + public static LocalTime addTo(LocalTime time, PlaceType placeType) { + return time.plus(placeType.getDuration()); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/Transportation.java b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/Transportation.java new file mode 100644 index 0000000..c33223a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/enums/Transportation.java @@ -0,0 +1,5 @@ +package org.withtime.be.withtimebe.domain.date.entity.enums; + +public enum Transportation { + WALK, CAR, PUBLICTRAN +} 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 new file mode 100644 index 0000000..bcbfb9b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDateCourse.java @@ -0,0 +1,18 @@ +package org.withtime.be.withtimebe.domain.date.entity.model; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Builder +@Getter +public class ScheduledDateCourse implements Comparable { + private List scheduledDatePlaces; + private double weight; + + @Override + public int compareTo(ScheduledDateCourse o) { + return (int)(this.weight - o.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 new file mode 100644 index 0000000..55b88dd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/entity/model/ScheduledDatePlace.java @@ -0,0 +1,30 @@ +package org.withtime.be.withtimebe.domain.date.entity.model; + +import lombok.Builder; +import lombok.Getter; +import org.withtime.be.withtimebe.domain.date.entity.DatePlace; + +import java.time.Duration; +import java.time.LocalTime; + +@Getter +public class ScheduledDatePlace { + private DatePlace datePlace; + private LocalTime startTime; + private LocalTime endTime; + private double score; + + // 그리고 여기서 그냥 comparator 쓰면 되지 않나? + + @Builder + public ScheduledDatePlace(DatePlace datePlace, LocalTime startTime, Duration duration, double score) { + this.datePlace = datePlace; + this.startTime = startTime; + this.endTime = startTime.plus(duration); + this.score = score; + } + + public Duration getDuration() { + return Duration.between(startTime, endTime); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseBookmarkRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseBookmarkRepository.java new file mode 100644 index 0000000..1d60c00 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseBookmarkRepository.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.date.entity.DateCourse; +import org.withtime.be.withtimebe.domain.date.entity.DateCourseBookmark; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +import java.util.Optional; + +public interface DateCourseBookmarkRepository extends JpaRepository { + Optional findByMemberAndDateCourse(Member member, DateCourse dateCourse); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java index dbef02c..58abab7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepository.java @@ -1,12 +1,11 @@ package org.withtime.be.withtimebe.domain.date.repository; -import java.time.LocalDate; -import java.time.LocalDateTime; - import org.springframework.data.jpa.repository.JpaRepository; import org.withtime.be.withtimebe.domain.date.entity.DateCourse; -public interface DateCourseRepository extends JpaRepository { +import java.time.LocalDateTime; + +public interface DateCourseRepository extends JpaRepository, DateCourseRepositoryCustom { Long countByCreatedAtBetween(LocalDateTime startTime, LocalDateTime endTime); Long countByMemberId(Long memberId); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryCustom.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryCustom.java new file mode 100644 index 0000000..ed52f3b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryCustom.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.member.entity.Member; + +public interface DateCourseRepositoryCustom { + public Page searchDateCourseByApplyPage(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable); + public Page searchDateCourseBookmarkByMemberAndApplyPage(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Member member, Pageable pageable); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryImpl.java new file mode 100644 index 0000000..3bbbcb0 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DateCourseRepositoryImpl.java @@ -0,0 +1,150 @@ +package org.withtime.be.withtimebe.domain.date.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.withtime.be.withtimebe.domain.date.dto.request.DateRequestDTO; +import org.withtime.be.withtimebe.domain.date.entity.*; +import org.withtime.be.withtimebe.domain.date.entity.enums.DatePriceRange; +import org.withtime.be.withtimebe.domain.date.entity.enums.DateTime; +import org.withtime.be.withtimebe.domain.date.entity.enums.MealType; +import org.withtime.be.withtimebe.domain.date.entity.enums.Transportation; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +import java.util.Collection; +import java.util.List; + +import static org.springframework.util.ObjectUtils.isEmpty; +import static org.withtime.be.withtimebe.domain.date.entity.QDateCourse.dateCourse; + + +@RequiredArgsConstructor +@Repository +public class DateCourseRepositoryImpl implements DateCourseRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + public Page searchDateCourseByApplyPage(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable) { + QDateCourse dateCourse = QDateCourse.dateCourse; + QDateCoursePlaceCategory dateCoursePlaceCategory = QDateCoursePlaceCategory.dateCoursePlaceCategory; + QPlaceCategory placeCategory = QPlaceCategory.placeCategory; + + boolean hasKeywords = dateCourseSearchCond.userPreferredKeywords() != null && !dateCourseSearchCond.userPreferredKeywords().isEmpty(); + + List content = queryFactory.selectFrom(dateCourse) + .where(datePriceRangeEq(dateCourseSearchCond.budget()), + datePlacesEq(dateCourseSearchCond.datePlaces()), + dateTimeEq(dateCourseSearchCond.dateDurationTime()), + mealTypesEq(dateCourseSearchCond.mealTypes()), + transportationEq(dateCourseSearchCond.transportation()), + hasKeywords ? existsAnyKeyword(dateCourse, dateCoursePlaceCategory, + placeCategory, dateCourseSearchCond.userPreferredKeywords()) : null + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(dateCourse.id.countDistinct()) + .from(dateCourse) + .where(datePriceRangeEq(dateCourseSearchCond.budget()), + datePlacesEq(dateCourseSearchCond.datePlaces()), + dateTimeEq(dateCourseSearchCond.dateDurationTime()), + mealTypesEq(dateCourseSearchCond.mealTypes()), + transportationEq(dateCourseSearchCond.transportation()), + hasKeywords ? existsAnyKeyword(dateCourse, dateCoursePlaceCategory, + placeCategory, dateCourseSearchCond.userPreferredKeywords()) : null + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0 : total); + } + + public Page searchDateCourseBookmarkByMemberAndApplyPage(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Member member, Pageable pageable){ + QDateCourse dateCourse = QDateCourse.dateCourse; + QDateCoursePlaceCategory dateCoursePlaceCategory = QDateCoursePlaceCategory.dateCoursePlaceCategory; + QPlaceCategory placeCategory = QPlaceCategory.placeCategory; + QDateCourseBookmark dateCourseBookmark = QDateCourseBookmark.dateCourseBookmark; + + boolean hasKeywords = dateCourseSearchCond.userPreferredKeywords() != null && !dateCourseSearchCond.userPreferredKeywords().isEmpty(); + + List content = queryFactory.selectFrom(dateCourse) + .join(dateCourseBookmark).on(dateCourseBookmark.dateCourse.eq(dateCourse)) + .where(dateCourseBookmark.member.id.eq(member.getId()), + datePriceRangeEq(dateCourseSearchCond.budget()), + datePlacesEq(dateCourseSearchCond.datePlaces()), + dateTimeEq(dateCourseSearchCond.dateDurationTime()), + mealTypesEq(dateCourseSearchCond.mealTypes()), + transportationEq(dateCourseSearchCond.transportation()), + hasKeywords ? existsAnyKeyword(dateCourse, dateCoursePlaceCategory, + placeCategory, dateCourseSearchCond.userPreferredKeywords()) : null + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(dateCourse.id.countDistinct()) + .from(dateCourse) + .join(dateCourseBookmark).on(dateCourseBookmark.dateCourse.eq(dateCourse)) + .where(dateCourseBookmark.member.id.eq(member.getId()), + datePriceRangeEq(dateCourseSearchCond.budget()), + datePlacesEq(dateCourseSearchCond.datePlaces()), + dateTimeEq(dateCourseSearchCond.dateDurationTime()), + mealTypesEq(dateCourseSearchCond.mealTypes()), + transportationEq(dateCourseSearchCond.transportation()), + hasKeywords ? existsAnyKeyword(dateCourse, dateCoursePlaceCategory, + placeCategory, dateCourseSearchCond.userPreferredKeywords()) : null + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0 : total); + } + + private BooleanExpression existsAnyKeyword( + QDateCourse dateCourse, + QDateCoursePlaceCategory dcpc, + QPlaceCategory placeCategory, + Collection labels + ){ + return JPAExpressions.selectOne() + .from(dcpc) + .join(placeCategory).on(placeCategory.eq(dcpc.placeCategory)) + .where( + dcpc.dateCourse.eq(dateCourse), + placeCategory.label.in(labels) + ).exists(); + } + + private BooleanExpression datePriceRangeEq(DatePriceRange datePriceRange){ + return datePriceRange == null ? null: dateCourse.datePriceRange.eq(datePriceRange); + } + + private BooleanExpression datePlacesEq(List datePlaces){ + return datePlaces.isEmpty() ? null : dateCourse.datePlaces.any().in(datePlaces); + } + + private BooleanExpression dateTimeEq(DateTime dateTime){ + return isEmpty(dateTime) ? null : dateCourse.dateTime.eq(dateTime); + } + + private BooleanExpression mealTypesEq(List mealTypes){ + return mealTypes.isEmpty() ? null : dateCourse.mealTypes.any().in(mealTypes); + } + + private BooleanExpression transportationEq(Transportation transportation){ + return isEmpty(transportation) ? null : dateCourse.transportation.eq(transportation); + } + + private BooleanExpression allEq(DatePriceRange datePriceRange, List datePlaces, + DateTime dateTime, List mealTypes, Transportation transportation + ){ + return datePriceRangeEq(datePriceRange).and(datePlacesEq(datePlaces)) + .and(dateTimeEq(dateTime)).and(mealTypesEq(mealTypes)) + .and(transportationEq(transportation)); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java index d6d9a9b..b9b642e 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/repository/DatePlaceRepository.java @@ -1,7 +1,20 @@ package org.withtime.be.withtimebe.domain.date.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.date.entity.DatePlace; +import java.util.List; + public interface DatePlaceRepository extends JpaRepository { + + + @Query("select d from DatePlace d" + + " where d.lotNumberAddress like %:keyword1%" + + " and d.lotNumberAddress like %:keyword2%") + List findByAddressContainingAll( + @Param("keyword1") String keyword1, + @Param("keyword2") String keyword2 + ); } 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 new file mode 100644 index 0000000..01b9cd5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandService.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.date.service.command; + +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.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); +} 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 new file mode 100644 index 0000000..c8c72a1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/command/DateCommandServiceImpl.java @@ -0,0 +1,247 @@ +package org.withtime.be.withtimebe.domain.date.service.command; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.date.converter.DateConverter; +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.entity.DatePlaceDateCourse; +import org.withtime.be.withtimebe.domain.date.entity.enums.BudgetLevel; +import org.withtime.be.withtimebe.domain.date.entity.enums.KeywordForBudget; +import org.withtime.be.withtimebe.domain.date.entity.enums.MealType; +import org.withtime.be.withtimebe.domain.date.entity.enums.PlaceType; +import org.withtime.be.withtimebe.domain.date.entity.model.ScheduledDateCourse; +import org.withtime.be.withtimebe.domain.date.entity.model.ScheduledDatePlace; +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.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; + + +@RequiredArgsConstructor +@Service +@Transactional +public class DateCommandServiceImpl implements DateCommandService{ + + private final DateCourseBookmarkRepository dateCourseBookmarkRepository; + 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); + } + 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()); + }; + + List> courses = scheduledDateCourses.stream() + .sorted() + .map(dateCourse -> dateCourse.getScheduledDatePlaces().stream() + .map(ScheduledDatePlace::getDatePlace).toList()) + .limit(4) + .toList(); + + 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<>(); + } + + private void assignScheduleTimes(List datePlaceDateCourse, LocalDateTime startedAt){ + LocalDateTime cursor = startedAt; + + for (DatePlaceDateCourse placeDateCourse : datePlaceDateCourse) { + placeDateCourse.setStartTime(cursor); + PlaceType placeType = placeDateCourse.getDatePlace().getPlaceType(); + Duration duration = placeType.getDuration(); + + LocalDateTime end = cursor.plus(duration); + placeDateCourse.setEndTime(end); + + cursor = end; + } + } + + 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) + .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); + } + + // 데이트코스 북마크 삭제 + 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); + } + + 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); + } else { + pattern.add(PlaceType.TIME_SEE); + } + } + } + return pattern; + } + + private List scorePlacesDto( + List datePlaces, + List userPreferredKeywords, + List budgetKeywords + ){ + return datePlaces.stream() + .map(place -> + { + List labels = place.getPlaceCategories().stream() + .map(dp -> dp.getPlaceCategory().getLabel()) + .toList(); + double score = 0.0; + + for (String label : labels) { + if (budgetKeywords.contains(label)){ + score += 1.0; + } + } + + for (String userPreferredKeyword : userPreferredKeywords) { + if (labels.contains(userPreferredKeyword)){ + score += 1.0; + } + } + + return ScheduledDatePlace.builder() + .datePlace(place) + .score(score) + .build(); + }) + .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); + return result.stream() + .map(list -> { + double score = list.stream() + .mapToDouble(ScheduledDatePlace::getScore) + .sum(); + return ScheduledDateCourse.builder() + .scheduledDatePlaces(list) + .weight(score) + .build(); + }) + .toList(); + } + + private void generateByRecur( + List pattern, + List allCandidates, + int index, + List current, + List> result + ){ + if (index == pattern.size()){ + result.add(new ArrayList<>(current)); + return; + } + PlaceType neededType = pattern.get(index); + + for (ScheduledDatePlace candidate : allCandidates) { + if (candidate.getDatePlace().getPlaceType() != neededType) continue; + if (current.contains(candidate)) continue; + + current.add(candidate); + generateByRecur(pattern, allCandidates, index + 1, current, result); + current.remove(current.size() - 1); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryService.java new file mode 100644 index 0000000..5431154 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryService.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.date.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.member.entity.Member; + +public interface DateQueryService { + public Page findDateCourses(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable); + public Page findDateCourseBookmarks(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable, Member member); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryServiceImpl.java new file mode 100644 index 0000000..00e4be5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/date/service/query/DateQueryServiceImpl.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.date.service.query; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.date.converter.DateConverter; +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.repository.DateCourseBookmarkRepository; +import org.withtime.be.withtimebe.domain.date.repository.DateCourseRepository; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DateQueryServiceImpl implements DateQueryService { + + private final DateCourseRepository dateCourseRepository; + + public Page findDateCourses(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable){ + return dateCourseRepository.searchDateCourseByApplyPage(dateCourseSearchCond, pageable); + } + + public Page findDateCourseBookmarks(DateRequestDTO.DateCourseSearchCond dateCourseSearchCond, Pageable pageable, Member member){ + return dateCourseRepository.searchDateCourseBookmarkByMemberAndApplyPage(dateCourseSearchCond, member, pageable); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/config/QueryDslConfig.java b/src/main/java/org/withtime/be/withtimebe/global/config/QueryDslConfig.java new file mode 100644 index 0000000..e039607 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package org.withtime.be.withtimebe.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + EntityManager em; + + @Bean + JPAQueryFactory jpaQueryFactory(EntityManager em){ + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/DateCourseErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/DateCourseErrorCode.java new file mode 100644 index 0000000..84838d1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/DateCourseErrorCode.java @@ -0,0 +1,35 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.ErrorReasonDTO; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum DateCourseErrorCode implements BaseErrorCode { + // 데이트 코스 에러 + DateCourse_NOT_FOUND(HttpStatus.NOT_FOUND, "DATE_COURSE404_1", "해당하는 데이트 코스를 찾을 수 없습니다."), + // 데이트 코스 입력 에러 + DateCourse_INVALID_INPUT(HttpStatus.BAD_REQUEST, "DATE_COURSE400_1", "유효하지 않은 입력값입니다"), + // 데이트 코스 예산 범위에 맞지 않다는 에러를 ENUM을 보여줘 + DateCourse_INVALID_BUDGET_RANGE(HttpStatus.BAD_REQUEST, "DATE_COURSE400_2", "데이트 코스 예산 범위가 올바르지 않습니다"), + + // 데이트 코스 북마크 에러 + DateCourseBookMark_NOT_FOUND(HttpStatus.NOT_FOUND, "DATE_COURSE_BOOKMARK404_1", "해당하는 북마크된 데이트 코스를 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/DateCourseException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/DateCourseException.java new file mode 100644 index 0000000..d05c7a7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/DateCourseException.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class DateCourseException extends ServerApplicationException { + + public DateCourseException(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } + +}