diff --git a/build.gradle b/build.gradle index 0e0a69d..60f15a0 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,8 @@ dependencies { implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' // QueryDSL @@ -57,6 +59,10 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Spring AI + implementation 'org.springframework.ai:spring-ai-core:1.0.0-M6' + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/ssafy/trabuddy/common/config/AiConfig.java b/src/main/java/com/ssafy/trabuddy/common/config/AiConfig.java new file mode 100644 index 0000000..5a4e1b9 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/common/config/AiConfig.java @@ -0,0 +1,15 @@ +package com.ssafy.trabuddy.common.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AiConfig { + + @Bean + ChatClient simpleChatClient(ChatClient.Builder builder) { + return builder.build(); + } +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/attraction/mapper/AttractionMapper.java b/src/main/java/com/ssafy/trabuddy/domain/attraction/mapper/AttractionMapper.java index 9b86b43..2fefff9 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/attraction/mapper/AttractionMapper.java +++ b/src/main/java/com/ssafy/trabuddy/domain/attraction/mapper/AttractionMapper.java @@ -3,15 +3,19 @@ import com.ssafy.trabuddy.domain.attraction.model.dto.AttractionSearchResponse; import com.ssafy.trabuddy.domain.attraction.model.dto.GetAttractionResponse; import com.ssafy.trabuddy.domain.attraction.model.entity.AttractionEntity; +import com.ssafy.trabuddy.domain.attraction.model.enums.AttractionSource; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchResponse; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.Named; import org.mapstruct.factory.Mappers; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import java.time.LocalDateTime; import java.util.List; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", imports = {LocalDateTime.class, AttractionSource.class}) public interface AttractionMapper { AttractionMapper INSTANCE = Mappers.getMapper(AttractionMapper.class); @@ -22,8 +26,46 @@ public interface AttractionMapper { List toAttractionSearchResponseList(List attractionEntities); + // 카카오 API에서 제공하지 않는 값은 빈 string이나 null 등 더미 값으로 설정 + @Mapping(source = "id", target = "contentId") + @Mapping(source = "placeName", target = "title") + @Mapping(source = "phone", target = "telephone", qualifiedByName = "nullSafeString") + @Mapping(source = "addressName", target = "address1", qualifiedByName = "nullSafeString") + @Mapping(source = "roadAddressName", target = "address2", qualifiedByName = "nullSafeString") + @Mapping(source = "y", target = "latitude", qualifiedByName = "stringToDouble") + @Mapping(source = "x", target = "longitude", qualifiedByName = "stringToDouble") + @Mapping(target = "attractionId", ignore = true) // 자동 생성 + @Mapping(target = "contentTypeId", constant = "") + @Mapping(target = "createdTime", expression = "java(LocalDateTime.now())") + @Mapping(target = "modifiedTime", expression = "java(LocalDateTime.now())") + @Mapping(target = "zipCode", constant = "") + @Mapping(target = "category1", constant = "") + @Mapping(target = "category2", constant = "") + @Mapping(target = "category3", constant = "") + @Mapping(target = "mapLevel", constant = "-1") + @Mapping(target = "firstImageUrl", constant = "") + @Mapping(target = "firstImageThumbnailUrl", constant = "") + @Mapping(target = "copyrightDivisionCode", constant = "") + @Mapping(target = "booktourInfo", constant = "") + @Mapping(target = "source", expression = "java(AttractionSource.kakao)") + @Mapping(target = "sigungu", ignore = true) + @Mapping(target = "area", ignore = true) + AttractionEntity toAttractionEntity(KakaoPlaceSearchResponse.Document document); + default Page toAttractionSearchResponsePage(Page attractionPage) { List responses = toAttractionSearchResponseList(attractionPage.getContent()); return new PageImpl<>(responses, attractionPage.getPageable(), attractionPage.getTotalElements()); } + + // latitude와 longitude 변환을 위한 커스텀 메서드 + @Named("stringToDouble") + default double stringToDouble(String value) { + return value != null ? Double.parseDouble(value) : 0.0; + } + + // telephone 필드 null 안전 처리 + @Named("nullSafeString") + default String nullSafeString(String value) { + return value != null ? value : ""; + } } diff --git a/src/main/java/com/ssafy/trabuddy/domain/attraction/model/dto/AttractionSearchResponse.java b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/dto/AttractionSearchResponse.java index 4bb2647..a834f4c 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/attraction/model/dto/AttractionSearchResponse.java +++ b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/dto/AttractionSearchResponse.java @@ -4,6 +4,7 @@ import com.ssafy.trabuddy.domain.sigungu.model.entity.SigunguEntity; import lombok.*; +import com.ssafy.trabuddy.domain.attraction.model.enums.AttractionSource; import java.time.LocalDateTime; @Getter @@ -34,6 +35,8 @@ public class AttractionSearchResponse { private String copyrightDivisionCode; private String booktourInfo; + private AttractionSource source; + private SigunguEntity sigungu; private AreaEntity area; diff --git a/src/main/java/com/ssafy/trabuddy/domain/attraction/model/entity/AttractionEntity.java b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/entity/AttractionEntity.java index 0436ec5..55c43a4 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/attraction/model/entity/AttractionEntity.java +++ b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/entity/AttractionEntity.java @@ -4,15 +4,18 @@ import com.ssafy.trabuddy.domain.sigungu.model.entity.SigunguEntity; import jakarta.persistence.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import com.ssafy.trabuddy.domain.attraction.model.enums.AttractionSource; import java.time.LocalDateTime; @Entity @Getter @NoArgsConstructor @AllArgsConstructor +@Builder @Table(name = "attraction") public class AttractionEntity { @Id @@ -39,6 +42,9 @@ public class AttractionEntity { private String copyrightDivisionCode; private String booktourInfo; + @Enumerated(EnumType.STRING) + private AttractionSource source; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "sigungu_code") private SigunguEntity sigungu; diff --git a/src/main/java/com/ssafy/trabuddy/domain/attraction/model/enums/AttractionSource.java b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/enums/AttractionSource.java new file mode 100644 index 0000000..47ce549 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/attraction/model/enums/AttractionSource.java @@ -0,0 +1,6 @@ +package com.ssafy.trabuddy.domain.attraction.model.enums; + +public enum AttractionSource { + kakao, + gov +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/attraction/repository/AttractionRepository.java b/src/main/java/com/ssafy/trabuddy/domain/attraction/repository/AttractionRepository.java index 16a08ca..d5540fa 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/attraction/repository/AttractionRepository.java +++ b/src/main/java/com/ssafy/trabuddy/domain/attraction/repository/AttractionRepository.java @@ -1,9 +1,12 @@ package com.ssafy.trabuddy.domain.attraction.repository; import com.ssafy.trabuddy.domain.attraction.model.entity.AttractionEntity; +import com.ssafy.trabuddy.domain.attraction.model.enums.AttractionSource; import com.ssafy.trabuddy.domain.attraction.repository.query.AttractionRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; -public interface AttractionRepository extends JpaRepository, AttractionRepositoryCustom { +import java.util.Optional; +public interface AttractionRepository extends JpaRepository, AttractionRepositoryCustom { + Optional findByContentIdAndSource(String contentId, AttractionSource source); } diff --git a/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/controller/KakaoPlaceSearchController.java b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/controller/KakaoPlaceSearchController.java new file mode 100644 index 0000000..c30c635 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/controller/KakaoPlaceSearchController.java @@ -0,0 +1,65 @@ +package com.ssafy.trabuddy.domain.kakaoPlaceSearch.controller; + +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchRequest; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchResponse; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.service.KakaoPlaceSearchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +//@RequestMapping("/kakao-place-search") +@RequiredArgsConstructor +@Slf4j +public class KakaoPlaceSearchController { + + private final KakaoPlaceSearchService kakaoPlaceSearchService; + + /** + * 키워드로 장소를 검색합니다. + * + * @param query 검색 키워드 + * @param categoryGroupCode 카테고리 그룹 코드 + * @param x 중심 좌표의 X값 (경도) + * @param y 중심 좌표의 Y값 (위도) + * @param radius 검색 반경 (미터) + * @param rect 사각형 범위 + * @param page 결과 페이지 번호 + * @param size 한 페이지에 보여질 문서 수 + * @param sort 정렬 옵션 (distance 또는 accuracy) + * @return 검색 결과 + */ + // @GetMapping("/keyword") + public ResponseEntity searchPlacesByKeyword( + @RequestParam String query, + @RequestParam(required = false) String categoryGroupCode, + @RequestParam(required = false) String x, + @RequestParam(required = false) String y, + @RequestParam(required = false) Integer radius, + @RequestParam(required = false) String rect, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String sort + ) { + // log.info("키워드 검색 요청: query={}, x={}, y={}, radius={}", query, x, y, radius); + + KakaoPlaceSearchRequest request = KakaoPlaceSearchRequest.builder() + .query(query) + .categoryGroupCode(categoryGroupCode) + .x(x) + .y(y) + .radius(radius) + .rect(rect) + .page(page) + .size(size) + .sort(sort) + .build(); + + KakaoPlaceSearchResponse response = kakaoPlaceSearchService.searchPlacesByKeyword(request); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchRequest.java b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchRequest.java new file mode 100644 index 0000000..7413538 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchRequest.java @@ -0,0 +1,22 @@ +package com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KakaoPlaceSearchRequest { + private String query; // 검색을 원하는 질의어 + private String categoryGroupCode; // 카테고리 그룹 코드 + private String x; // 중심 좌표의 X값 (경도) + private String y; // 중심 좌표의 Y값 (위도) + private Integer radius; // 반경 (미터) + private String rect; // 사각형 범위 + private Integer page; // 결과 페이지 번호 + private Integer size; // 한 페이지에 보여질 문서 수 + private String sort; // 정렬 옵션 (distance 또는 accuracy) +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchResponse.java b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchResponse.java new file mode 100644 index 0000000..dde4ac4 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/model/dto/KakaoPlaceSearchResponse.java @@ -0,0 +1,84 @@ +package com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class KakaoPlaceSearchResponse { + private Meta meta; + private List documents; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class Meta { + @JsonProperty("total_count") + private int totalCount; + + @JsonProperty("pageable_count") + private int pageableCount; + + @JsonProperty("is_end") + private boolean isEnd; + + @JsonProperty("same_name") + private SameName sameName; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class SameName { + private List region; + private String keyword; + + @JsonProperty("selected_region") + private String selectedRegion; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class Document { + @JsonProperty("place_name") + private String placeName; + + private String distance; + + @JsonProperty("place_url") + private String placeUrl; + + @JsonProperty("category_name") + private String categoryName; + + @JsonProperty("address_name") + private String addressName; + + @JsonProperty("road_address_name") + private String roadAddressName; + + private String id; + + private String phone; + + @JsonProperty("category_group_code") + private String categoryGroupCode; + + @JsonProperty("category_group_name") + private String categoryGroupName; + + private String x; // longitude + private String y; // latitude + } +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/service/KakaoPlaceSearchService.java b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/service/KakaoPlaceSearchService.java new file mode 100644 index 0000000..db28f25 --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/kakaoPlaceSearch/service/KakaoPlaceSearchService.java @@ -0,0 +1,110 @@ +package com.ssafy.trabuddy.domain.kakaoPlaceSearch.service; + +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchRequest; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoPlaceSearchService { + + private final RestTemplate restTemplate; + + @Value("${kakao.local.restApi}") + private String kakaoLocalApiKey; + + private static final String KAKAO_LOCAL_SEARCH_KEYWORD_URL = "https://dapi.kakao.com/v2/local/search/keyword.json"; + + /** + * 키워드로 장소를 검색합니다. + * + * @param request 검색 요청 정보 + * @return 검색 결과 + */ + public KakaoPlaceSearchResponse searchPlacesByKeyword(KakaoPlaceSearchRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoLocalApiKey); + + URI uri = buildSearchUri(request); + + ResponseEntity response = restTemplate.exchange( + uri, + HttpMethod.GET, + new HttpEntity<>(headers), + KakaoPlaceSearchResponse.class + ); + + // log.info("Kakao Local API 검색 결과: {}", response.getBody()); + + return response.getBody(); + } + + /** + * 키워드로 장소를 검색합니다 (간단한 버전). + * + * @param query 검색 키워드 + * @return 검색 결과 + */ + public KakaoPlaceSearchResponse searchPlacesByKeyword(String query) { + KakaoPlaceSearchRequest request = KakaoPlaceSearchRequest.builder() + .query(query) + .size(1) // 첫 번째 결과만 가져오기 + .build(); + + return searchPlacesByKeyword(request); + } + + /** + * 검색 요청 정보를 기반으로 URI를 생성합니다. + * + * @param request 검색 요청 정보 + * @return 생성된 URI + */ + private URI buildSearchUri(KakaoPlaceSearchRequest request) { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(KAKAO_LOCAL_SEARCH_KEYWORD_URL) + .queryParam("query", request.getQuery()); + + if (request.getCategoryGroupCode() != null) { + builder.queryParam("category_group_code", request.getCategoryGroupCode()); + } + + if (request.getX() != null && request.getY() != null) { + builder.queryParam("x", request.getX()) + .queryParam("y", request.getY()); + + if (request.getRadius() != null) { + builder.queryParam("radius", request.getRadius()); + } + } + + if (request.getRect() != null) { + builder.queryParam("rect", request.getRect()); + } + + if (request.getPage() != null) { + builder.queryParam("page", request.getPage()); + } + + if (request.getSize() != null) { + builder.queryParam("size", request.getSize()); + } + + if (request.getSort() != null) { + builder.queryParam("sort", request.getSort()); + } + + return builder.build(true).toUri(); + } +} diff --git a/src/main/java/com/ssafy/trabuddy/domain/plan/controller/PlanController.java b/src/main/java/com/ssafy/trabuddy/domain/plan/controller/PlanController.java index 5ba8109..964ecb9 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/plan/controller/PlanController.java +++ b/src/main/java/com/ssafy/trabuddy/domain/plan/controller/PlanController.java @@ -1,5 +1,8 @@ package com.ssafy.trabuddy.domain.plan.controller; +import com.ssafy.trabuddy.domain.attraction.model.dto.AttractionSearchResponse; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchResponse; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.service.KakaoPlaceSearchService; import com.ssafy.trabuddy.domain.member.model.dto.LoggedInMember; import com.ssafy.trabuddy.domain.plan.model.dto.AddPlanRequest; import com.ssafy.trabuddy.domain.plan.model.dto.GetPlanResponse; @@ -23,6 +26,7 @@ public class PlanController { private final PlanService planService; private final PlanLikeService planLikeService; + private final KakaoPlaceSearchService kakaoPlaceSearchService; @PostMapping("/v1/plans") public ResponseEntity addPlan(@Valid @RequestBody AddPlanRequest request, @AuthenticationPrincipal LoggedInMember loggedInMember) { @@ -59,16 +63,28 @@ public ResponseEntity> getOpenPlans() { @PostMapping("/v1/plans/{planId}/like") public ResponseEntity togglePlanLike(@PathVariable Long planId) { log.info("PlanController - togglePlanLike 요청 - planId: {}", planId); - + // SecurityContext에서 인증된 사용자 정보 가져오기 var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof LoggedInMember)) { log.error("인증되지 않은 사용자의 좋아요 요청"); return ResponseEntity.status(401).build(); } - + LoggedInMember loggedInMember = (LoggedInMember) authentication.getPrincipal(); PlanLikeResponse response = planLikeService.togglePlanLike(planId, loggedInMember); return ResponseEntity.ok(response); } + + @GetMapping("/v1/plans/{planId}/recommendation") + public ResponseEntity> getRecommendedAttractions(@PathVariable long planId, @AuthenticationPrincipal LoggedInMember loggedInMember) { + List response = planService.getRecommendedAttractions(planId); + return ResponseEntity.ok(response); + } + + @GetMapping("/v1/plans/search-place") + public ResponseEntity searchPlace(@RequestParam String query) { + KakaoPlaceSearchResponse response = kakaoPlaceSearchService.searchPlacesByKeyword(query); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/ssafy/trabuddy/domain/plan/service/PlanService.java b/src/main/java/com/ssafy/trabuddy/domain/plan/service/PlanService.java index af2897f..d4ac776 100644 --- a/src/main/java/com/ssafy/trabuddy/domain/plan/service/PlanService.java +++ b/src/main/java/com/ssafy/trabuddy/domain/plan/service/PlanService.java @@ -1,6 +1,13 @@ package com.ssafy.trabuddy.domain.plan.service; import com.ssafy.trabuddy.common.util.NullPropertyUtils; +import com.ssafy.trabuddy.domain.attraction.mapper.AttractionMapper; +import com.ssafy.trabuddy.domain.attraction.model.dto.AttractionSearchResponse; +import com.ssafy.trabuddy.domain.attraction.model.entity.AttractionEntity; +import com.ssafy.trabuddy.domain.attraction.model.enums.AttractionSource; +import com.ssafy.trabuddy.domain.attraction.repository.AttractionRepository; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.model.dto.KakaoPlaceSearchResponse; +import com.ssafy.trabuddy.domain.kakaoPlaceSearch.service.KakaoPlaceSearchService; import com.ssafy.trabuddy.domain.member.model.dto.LoggedInMember; import com.ssafy.trabuddy.domain.member.model.entity.MemberEntity; import com.ssafy.trabuddy.domain.member.model.enums.MemberRole; @@ -16,13 +23,17 @@ import com.ssafy.trabuddy.domain.plan.repository.PlanRepository; import com.ssafy.trabuddy.domain.planShare.model.entity.PlanShareEntity; import com.ssafy.trabuddy.domain.planShare.service.PlanShareService; +import com.ssafy.trabuddy.domain.recommendation.service.RecommendationService; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; +import java.net.URLEncoder; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -32,6 +43,9 @@ public class PlanService { private final PlanRepository planRepository; private final MemberRepository memberRepository; private final PlanShareService planShareService; + private final AttractionRepository attractionRepository; + private final RecommendationService recommendationService; + private final KakaoPlaceSearchService kakaoPlaceSearchService; public GetPlanResponse addPlan(AddPlanRequest addPlanRequest, LoggedInMember loggedInMembers) { PlanEntity planEntity = planRepository.save(PlanMapper.INSTANCE.toPlanEntity(addPlanRequest, loggedInMembers)); @@ -74,11 +88,11 @@ public List getPlans(LoggedInMember loggedInMember) { */ public List getOpenPlans() { log.info("PlanService - getOpenPlans 시작"); - + List openPlans = planRepository.findByVisibilityAndDeletedAtIsNull(PlanVisibility.open); - + log.info("공개 플랜 조회 완료 - count: {}", openPlans.size()); - + return openPlans.stream() .map(PlanMapper.INSTANCE::toGetPlanResponse) .collect(Collectors.toList()); @@ -100,4 +114,66 @@ public PlanEntity findById(long planId) { return planRepository.findById(planId) .orElseThrow(() -> new PlanNotFoundException(PlanErrorCode.NOT_FOUND_PLAN)); } + + public List getRecommendedAttractions(long planId) { + // 1. planRepository를 통해 plan 정보 가져오기 + PlanEntity plan = planRepository.findById(planId) + .orElseThrow(() -> new PlanNotFoundException(PlanErrorCode.NOT_FOUND_PLAN)); + + // 2. RecommendationService의 함수에 파라미터 넣어서 추천 여행지 목록 받기 + List recommendations = recommendationService.recommend( + 10, // 추천 개수 (기본값 10개) + plan.getPeople(), + plan.getStartDate(), + plan.getEndDate(), + plan.getThemeId() + ); + + List attractionEntities = new ArrayList<>(); + + // 3. 각 추천 여행지에 대해 카카오 장소 검색 실행 + for (RecommendationService.Recommendation recommendation : recommendations) { + try { + // 카카오 장소 검색 실행 + KakaoPlaceSearchResponse kakaoResponse = kakaoPlaceSearchService.searchPlacesByKeyword( + URLEncoder.encode(recommendation.name(), "UTF-8")); + + // 검색 결과가 있는 경우 + if (kakaoResponse != null && + kakaoResponse.getDocuments() != null && + !kakaoResponse.getDocuments().isEmpty()) { + + KakaoPlaceSearchResponse.Document document = kakaoResponse.getDocuments().get(0); + + // 4. attraction 테이블에서 중복 확인 + Optional existingAttraction = attractionRepository + .findByContentIdAndSource(document.getId(), AttractionSource.kakao); + + AttractionEntity attractionEntity; + + if (existingAttraction.isPresent()) { + // 이미 존재하는 경우 기존 엔티티 사용 + attractionEntity = existingAttraction.get(); + } else { + // 존재하지 않는 경우 새로운 AttractionEntity 생성 및 저장 + // MapStruct를 사용하여 Document를 AttractionEntity로 변환 + attractionEntity = AttractionMapper.INSTANCE.toAttractionEntity(document); + + // 새로운 엔티티를 데이터베이스에 저장 + attractionEntity = attractionRepository.save(attractionEntity); + } + + attractionEntities.add(attractionEntity); + } + } catch (Exception e) { + log.warn("카카오 장소 검색 중 오류 발생: {}, 추천지: {}", e.getMessage(), recommendation.name()); + // 오류가 발생한 경우 해당 추천지는 스킵하고 계속 진행 + } + } + + // 5. AttractionEntity 목록을 AttractionSearchResponse 목록으로 변환하여 반환 + return attractionEntities.stream() + .map(AttractionMapper.INSTANCE::toAttractionSearchResponse) + .toList(); + } } diff --git a/src/main/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationService.java b/src/main/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationService.java new file mode 100644 index 0000000..3424a2a --- /dev/null +++ b/src/main/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationService.java @@ -0,0 +1,56 @@ +package com.ssafy.trabuddy.domain.recommendation.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecommendationService { + public record Recommendation(String name, String reason) { + } + + private static final List themes + = List.of("자유", "힐링/휴식", "먹방", "혼자", "무계획", "레저/스포츠"); + + @Qualifier("simpleChatClient") + private final ChatClient simpleChatClient; + + public List recommend(int count, int people, LocalDateTime startDate, LocalDateTime endDate, int themeId) { + PromptTemplate pt = new PromptTemplate(""" + {people}명의 사람들이 {startDate}부터 {endDate}까지 여행하려고 합니다. + 한국의 여행지를 {count}개 추천해주세요. 여행 테마는 {theme}입니다. + 여행지 추천은 여행지 이름(name)과 추천 이유(reason)가 필요합니다. + 여행지 이름은 부가 설명 없이 구체적인 지명이나 장소를 적어주세요. + 추천 이유는 30자 내외로 간결하게, 해당 여행지를 모르는 사람에게 설명하듯이 친절하게 동사 형태로 적어주세요. + """); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + String formattedStartDate = startDate.format(formatter); + String formattedEndDate = endDate.format(formatter); + String theme = themeId < themes.size() ? themes.get(themeId) : themes.get(0); + + Prompt prompt = pt.create(Map.of( + "count", count, + "people", people, + "startDate", formattedStartDate, + "endDate", formattedEndDate, + "theme", theme)); + + return simpleChatClient.prompt(prompt) + .call() + .entity(new ParameterizedTypeReference>() { + }); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6c8c25e..8ff9849 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,7 +15,13 @@ spring: redis: host: localhost port: 6379 - + ai: + openai: + api-key: ${openaiApiKey} + chat: + options: + model: gpt-3.5-turbo + temperature: 0.7 server: servlet: context-path: /api @@ -25,6 +31,8 @@ kakao: login: restApi: ${restAPI} redirectUri: ${redirectUri} + local: + restApi: ${restAPI} jwt: accessExpiration: ${accessExpiration} @@ -53,6 +61,3 @@ springdoc: filter: true # 필터 바 표시 display-request-duration: true # 요청 소요시간 표시 enabled: true - - - diff --git a/src/test/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationServiceTest.java new file mode 100644 index 0000000..0438776 --- /dev/null +++ b/src/test/java/com/ssafy/trabuddy/domain/recommendation/service/RecommendationServiceTest.java @@ -0,0 +1,31 @@ +package com.ssafy.trabuddy.domain.recommendation.service; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; + +@Slf4j +@SpringBootTest +public class RecommendationServiceTest { + + @Autowired + RecommendationService recommendationService; + + @Test + void recommendTest() throws Exception { + var startDate = LocalDateTime.now(); + var endDate = LocalDateTime.now().plusDays(7); + + var rec = recommendationService.recommend( + 5, + 2, + startDate, + endDate, + 2 + ); + log.info("content: {}", rec); + } +} \ No newline at end of file