diff --git a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java index 9ca8a30..d423044 100644 --- a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java +++ b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java @@ -164,7 +164,7 @@ public ApiResponse addSearchHistoryToLibrary( - group1, group2 : 각 라이브러리 기본 정보 - keyCharacteristics : 주요 특성 비교 (차이점 포함) - comparisonCharts : 비교 차트 데이터 (Chart.js 형식) - - basicComparison : 기본 통계 비교 + - comparisons : 기본 통계 비교 ## 권한 본인이 생성한 라이브러리만 비교할 수 있습니다. @@ -181,28 +181,28 @@ public ApiResponse compareLibraries( return ApiResponse.onSuccess(result); } - @PostMapping("/compare/test") - @Operation(summary = "라이브러리 비교 (테스트용)", description = """ - ## 개요 - AI 서버 연동 없이 하드코딩된 테스트 데이터로 라이브러리 비교를 제공하는 API입니다. - 개발 및 테스트 목적으로 사용됩니다. - - ## Request Body - - libraryId1 : 첫 번째 라이브러리 ID (필수) - - libraryId2 : 두 번째 라이브러리 ID (필수) - - ## 응답 데이터 - - 하드코딩된 테스트 데이터 반환 - - 실제 라이브러리 데이터는 사용하지 않음 - - ## 권한 - 인증된 사용자만 접근 가능합니다. - - """) - public ApiResponse compareLibrariesTest( - @RequestBody @Valid LibraryCompareRequestDTO.Compare request) { - Member member = currentUserService.getCurrentUser(); - LibraryCompareResponseDTO.CompareResult result = libraryService.compareLibrariesTest(request, member); - return ApiResponse.onSuccess(result); - } +// @PostMapping("/compare/test") +// @Operation(summary = "라이브러리 비교 (테스트용)", description = """ +// ## 개요 +// AI 서버 연동 없이 하드코딩된 테스트 데이터로 라이브러리 비교를 제공하는 API입니다. +// 개발 및 테스트 목적으로 사용됩니다. +// +// ## Request Body +// - libraryId1 : 첫 번째 라이브러리 ID (필수) +// - libraryId2 : 두 번째 라이브러리 ID (필수) +// +// ## 응답 데이터 +// - 하드코딩된 테스트 데이터 반환 +// - 실제 라이브러리 데이터는 사용하지 않음 +// +// ## 권한 +// 인증된 사용자만 접근 가능합니다. +// +// """) +// public ApiResponse compareLibrariesTest( +// @RequestBody @Valid LibraryCompareRequestDTO.Compare request) { +// Member member = currentUserService.getCurrentUser(); +// LibraryCompareResponseDTO.CompareResult result = libraryService.compareLibrariesTest(request, member); +// return ApiResponse.onSuccess(result); +// } } diff --git a/src/main/java/DiffLens/back_end/domain/library/dto/LibraryCompareResponseDTO.java b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryCompareResponseDTO.java index a917505..02353e4 100644 --- a/src/main/java/DiffLens/back_end/domain/library/dto/LibraryCompareResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryCompareResponseDTO.java @@ -7,7 +7,6 @@ import lombok.NoArgsConstructor; import java.util.List; -import java.util.Map; public class LibraryCompareResponseDTO { @@ -23,14 +22,14 @@ public static class CompareResult { @JsonProperty("group2") private GroupInfo group2; - @JsonProperty("keyCharacteristics") + @JsonProperty("key_characteristics") private List keyCharacteristics; - @JsonProperty("comparisonCharts") - private List comparisonCharts; + @JsonProperty("basic_comparison") + private Comparisons comparisons; - @JsonProperty("basicComparison") - private List basicComparison; + @JsonProperty("insights") + private Insights insights; } @Getter @@ -39,19 +38,38 @@ public static class CompareResult { @AllArgsConstructor public static class GroupInfo { - @JsonProperty("libraryId") + @JsonProperty("library_id") private Long libraryId; - @JsonProperty("libraryName") + @JsonProperty("library_name") private String libraryName; - @JsonProperty("totalCount") + @JsonProperty("summary") + private String summary; + + @JsonProperty("total_count") private Integer totalCount; + @JsonProperty("filters") + private List filters; + @JsonProperty("color") private String color; } + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Filter { + + @JsonProperty("key") + private String key; + + @JsonProperty("values") + private List values; + } + @Getter @Builder @NoArgsConstructor @@ -64,10 +82,10 @@ public static class KeyCharacteristic { @JsonProperty("description") private String description; - @JsonProperty("group1Percentage") + @JsonProperty("group1_percentage") private Integer group1Percentage; - @JsonProperty("group2Percentage") + @JsonProperty("group2_percentage") private Integer group2Percentage; @JsonProperty("difference") @@ -78,34 +96,71 @@ public static class KeyCharacteristic { @Builder @NoArgsConstructor @AllArgsConstructor - public static class ComparisonChart { + public static class Comparisons { - @JsonProperty("chartType") - private String chartType; + @JsonProperty("group1") + private GroupMetrics group1; - @JsonProperty("title") - private String title; + @JsonProperty("group2") + private GroupMetrics group2; + } - @JsonProperty("data") - private Map data; + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class GroupMetrics { + + @JsonProperty("male") + private Integer male; + + @JsonProperty("female") + private Integer female; + + @JsonProperty("seoul") + private Integer seoul; + + @JsonProperty("gyeonggi") + private Integer gyeonggi; + + @JsonProperty("busan") + private Integer busan; - @JsonProperty("options") - private Map options; + @JsonProperty("region_etc") + private Integer regionEtc; + + @JsonProperty("avg_age") + private Double avgAge; + + @JsonProperty("avg_family") + private Double avgFamily; + + @JsonProperty("avg_children") + private Double avgChildren; + + @JsonProperty("rate_possessing_car") + private Integer ratePossessingCar; + + @JsonProperty("avg_personal_income") + private Integer avgPersonalIncome; + + @JsonProperty("avg_family_income") + private Integer avgFamilyIncome; } @Getter @Builder @NoArgsConstructor @AllArgsConstructor - public static class BasicComparison { + public static class Insights { - @JsonProperty("metric") - private String metric; + @JsonProperty("difference") + private String difference; - @JsonProperty("group1Value") - private String group1Value; + @JsonProperty("common") + private String common; - @JsonProperty("group2Value") - private String group2Value; + @JsonProperty("implication") + private String implication; } } diff --git a/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java b/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java index fca648d..b0e40ba 100644 --- a/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java +++ b/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java @@ -15,7 +15,12 @@ import DiffLens.back_end.domain.members.entity.Member; import DiffLens.back_end.domain.panel.entity.Panel; import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.search.entity.Filter; +import DiffLens.back_end.domain.search.entity.SearchFilter; import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.enums.filters.Gender; +import DiffLens.back_end.domain.search.repository.FilterRepository; +import DiffLens.back_end.domain.search.repository.SearchFilterRepository; import DiffLens.back_end.domain.search.repository.SearchHistoryRepository; import DiffLens.back_end.global.fastapi.FastApiService; import DiffLens.back_end.global.fastapi.dto.request.FastLibraryRequestDTO; @@ -26,8 +31,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -41,6 +45,8 @@ public class LibraryService { private final SearchHistoryRepository searchHistoryRepository; private final PanelRepository panelRepository; private final FastApiService fastApiService; + private final SearchFilterRepository searchFilterRepository; + private final FilterRepository filterRepository; @Transactional public LibraryCreateResult createLibrary(LibraryRequestDto.Create request, Member member) { @@ -292,8 +298,7 @@ private LibraryResponseDTO.LibraryDetail.Statistics createStatistics(List } @Transactional(readOnly = true) - public LibraryCompareResponseDTO.CompareResult compareLibraries(LibraryCompareRequestDTO.Compare request, - Member member) { + public LibraryCompareResponseDTO.CompareResult compareLibraries(LibraryCompareRequestDTO.Compare request, Member member) { // 1. 요청 검증 - 같은 라이브러리 비교 불가 if (request.getLibraryId1().equals(request.getLibraryId2())) { throw new ErrorHandler(ErrorStatus.BAD_REQUEST); @@ -328,200 +333,127 @@ public LibraryCompareResponseDTO.CompareResult compareLibraries(LibraryCompareRe .group1(LibraryCompareResponseDTO.GroupInfo.builder() .libraryId(library1.getId()) .libraryName(library1.getLibraryName()) +// .summary() .totalCount(library1.getPanelIds().size()) + .filters(convertFilters(library1)) .color("#4169E1") .build()) .group2(LibraryCompareResponseDTO.GroupInfo.builder() .libraryId(library2.getId()) .libraryName(library2.getLibraryName()) +// .summary() .totalCount(library2.getPanelIds().size()) + .filters(convertFilters(library2)) .color("#32CD32") .build()) .keyCharacteristics(convertKeyCharacteristics(fastApiResponse.getKeyCharacteristics())) - .comparisonCharts(convertComparisonCharts(fastApiResponse.getComparisonCharts())) - .basicComparison(convertBasicComparison(fastApiResponse.getBasicComparison())) + .comparisons(convertComparisons(fastApiResponse.getBasicComparison(), library1, library2)) + .insights(convertInsights(fastApiResponse.getAiInsights())) .build(); } - @Transactional(readOnly = true) - public LibraryCompareResponseDTO.CompareResult compareLibrariesTest(LibraryCompareRequestDTO.Compare request, - Member member) { - // 1. 요청 검증 - 같은 라이브러리 비교 불가 - if (request.getLibraryId1().equals(request.getLibraryId2())) { - throw new ErrorHandler(ErrorStatus.BAD_REQUEST); - } - - // 2. 라이브러리 조회 및 권한 확인 (실제 데이터는 사용하지 않지만 검증은 함) - Library library1 = libraryRepository.findById(request.getLibraryId1()) - .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); - - Library library2 = libraryRepository.findById(request.getLibraryId2()) - .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + private LibraryCompareResponseDTO.Comparisons convertComparisons(List comparisons, Library library1, Library library2) { + return LibraryCompareResponseDTO.Comparisons.builder() + .group1(getGroupMetrics(library1)) + .group2(getGroupMetrics(library2)) + .build(); + } - // 3. 권한 검증 - 본인의 라이브러리만 비교 가능 - if (!library1.getMember().getId().equals(member.getId()) || - !library2.getMember().getId().equals(member.getId())) { - throw new ErrorHandler(ErrorStatus.FORBIDDEN); + private LibraryCompareResponseDTO.GroupMetrics getGroupMetrics(Library library) { + List panels = panelRepository.findByIdList(library.getPanelIds()); + int total = panels.size(); + if (total == 0) { + return LibraryCompareResponseDTO.GroupMetrics.builder() + .male(0).female(0) + .seoul(0).gyeonggi(0).busan(0).regionEtc(0) + .ratePossessingCar(0) + .avgAge(0.0) + .avgFamily(0.0) + .avgChildren(0.0) + .avgPersonalIncome(0) + .avgFamilyIncome(0) + .build(); } - // 4. 하드코딩된 테스트 데이터 반환 - return createTestCompareResult(library1, library2); + long maleCount = panels.stream().filter(p -> p.getGender() != null && p.getGender().toString().equals(Gender.MALE)).count(); + long femaleCount = panels.stream().filter(p -> p.getGender() != null && p.getGender().toString().equals(Gender.FEMALE)).count(); + + long seoul = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("서울")).count(); + long gyeonggi = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("경기")).count(); + long busan = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("부산")).count(); + long regionEtc = total - seoul - gyeonggi - busan; + + long carOwners = panels.stream().filter(p -> p.getCarOwnership() != null && p.getCarOwnership().contains("있음")).count(); + + double avgAge = panels.stream() + .filter(p -> p.getAge() != null) + .mapToInt(Panel::getAge) + .average() + .orElse(0); + + double avgFamily = panels.stream() + .filter(p -> p.getFamilySize() != null) + .mapToInt(p -> { + try { + return Integer.parseInt(p.getFamilySize()); + } catch (NumberFormatException e) { + return 0; + } + }).average().orElse(0); + + double avgChildren = panels.stream() + .filter(p -> p.getChildrenCount() != null) + .mapToInt(Panel::getChildrenCount) + .average() + .orElse(0); + + double avgPersonalIncome = panels.stream() + .filter(p -> p.getPersonalIncome() != null) + .mapToInt(p -> parseIncome(p.getPersonalIncome())) + .average() + .orElse(0); + + double avgFamilyIncome = panels.stream() + .filter(p -> p.getHouseholdIncome() != null) + .mapToInt(p -> parseIncome(p.getHouseholdIncome())) + .average() + .orElse(0); + + return LibraryCompareResponseDTO.GroupMetrics.builder() + .male((int) Math.round((double) maleCount / total * 100)) + .female((int) Math.round((double) femaleCount / total * 100)) + .seoul((int) Math.round((double) seoul / total * 100)) + .gyeonggi((int) Math.round((double) gyeonggi / total * 100)) + .busan((int) Math.round((double) busan / total * 100)) + .regionEtc((int) Math.round((double) regionEtc / total * 100)) + .ratePossessingCar((int) Math.round((double) carOwners / total * 100)) + .avgAge(avgAge) + .avgFamily(avgFamily) + .avgChildren(avgChildren) + .avgPersonalIncome((int) avgPersonalIncome) + .avgFamilyIncome((int) avgFamilyIncome) + .build(); } - private LibraryCompareResponseDTO.CompareResult createTestCompareResult(Library library1, Library library2) { - // 하드코딩된 테스트 데이터 생성 - List keyCharacteristics = List.of( - LibraryCompareResponseDTO.KeyCharacteristic.builder() - .characteristic("특성1") - .description("차량 보유율") - .group1Percentage(30) - .group2Percentage(70) - .difference(40) - .build(), - LibraryCompareResponseDTO.KeyCharacteristic.builder() - .characteristic("특성2") - .description("고소득 비율") - .group1Percentage(32) - .group2Percentage(68) - .difference(36) - .build(), - LibraryCompareResponseDTO.KeyCharacteristic.builder() - .characteristic("특성3") - .description("기혼 비율") - .group1Percentage(32) - .group2Percentage(68) - .difference(36) - .build(), - LibraryCompareResponseDTO.KeyCharacteristic.builder() - .characteristic("특성4") - .description("OTT 구독률") - .group1Percentage(40) - .group2Percentage(60) - .difference(20) - .build(), - LibraryCompareResponseDTO.KeyCharacteristic.builder() - .characteristic("특성5") - .description("해외여행 경험") - .group1Percentage(47) - .group2Percentage(53) - .difference(6) - .build()); - - List comparisonCharts = List.of( - LibraryCompareResponseDTO.ComparisonChart.builder() - .chartType("bar") - .title("직업 분포") - .data(Map.of( - "labels", List.of("경영/관리직", "사무직", "자영업", "학생", "프리랜서"), - "datasets", List.of( - Map.of( - "label", "20대 남성", - "data", - List.of(10, 45, 20, 15, - 10), - "backgroundColor", - "#4169E1"), - Map.of( - "label", "40대 여성", - "data", - List.of(25, 50, 15, 5, - 5), - "backgroundColor", - "#32CD32")))) - .options(Map.of( - "scales", Map.of( - "y", Map.of( - "beginAtZero", true, - "title", Map.of( - "display", - true, - "text", - "패널 수 (%)"))), - "plugins", Map.of( - "legend", Map.of("position", "top")))) - .build(), - LibraryCompareResponseDTO.ComparisonChart.builder() - .chartType("line") - .title("월평균 개인소득") - .data(Map.of( - "labels", List.of( - "100만원 미만", "100-199만원", "200-299만원", - "300-399만원", "400-499만원", "500만원 이상"), - "datasets", List.of( - Map.of( - "label", "20대 남성", - "data", - List.of(5, 15, 25, 30, - 20, 5), - "borderColor", - "#4169E1", - "backgroundColor", - "rgba(65, 105, 225, 0.1)", - "tension", 0.4, - "fill", true), - Map.of( - "label", "40대 여성", - "data", - List.of(2, 8, 15, 25, - 30, 20), - "borderColor", - "#32CD32", - "backgroundColor", - "rgba(50, 205, 50, 0.1)", - "tension", 0.4, - "fill", true)))) - .options(Map.of( - "scales", Map.of( - "y", Map.of( - "beginAtZero", true, - "title", Map.of( - "display", - true, - "text", - "패널 수 (%)"))), - "plugins", Map.of( - "legend", Map.of("position", "top")))) - .build()); - - List basicComparison = List.of( - LibraryCompareResponseDTO.BasicComparison.builder() - .metric("평균 연령") - .group1Value("24.5세") - .group2Value("43.2세") - .build(), - LibraryCompareResponseDTO.BasicComparison.builder() - .metric("평균 소득") - .group1Value("250만원") - .group2Value("320만원") - .build(), - LibraryCompareResponseDTO.BasicComparison.builder() - .metric("기혼 비율") - .group1Value("15%") - .group2Value("85%") - .build()); - - return LibraryCompareResponseDTO.CompareResult.builder() - .group1(LibraryCompareResponseDTO.GroupInfo.builder() - .libraryId(library1.getId()) - .libraryName(library1.getLibraryName()) - .totalCount(library1.getPanelIds().size()) - .color("#4169E1") - .build()) - .group2(LibraryCompareResponseDTO.GroupInfo.builder() - .libraryId(library2.getId()) - .libraryName(library2.getLibraryName()) - .totalCount(library2.getPanelIds().size()) - .color("#32CD32") - .build()) - .keyCharacteristics(keyCharacteristics) - .comparisonCharts(comparisonCharts) - .basicComparison(basicComparison) - .build(); + // 문자열 -> 숫자 + private int parseIncome(String incomeStr) { + if (incomeStr == null) return 0; + String clean = incomeStr.replaceAll("[^0-9]", ""); + if (clean.isEmpty()) return 0; + try { + return Integer.parseInt(clean); + } catch (NumberFormatException e) { + return 0; + } } + private List convertKeyCharacteristics( List fastApiCharacteristics) { - return fastApiCharacteristics.stream() + + int lastIndex = Math.min(fastApiCharacteristics.size(), 3); + + return fastApiCharacteristics.subList(0, lastIndex-1).stream() .map(fast -> LibraryCompareResponseDTO.KeyCharacteristic.builder() .characteristic(fast.getCharacteristic()) .description(fast.getDescription()) @@ -532,29 +464,40 @@ private List convertKeyCharacterist .toList(); } - private List convertComparisonCharts( - List fastApiCharts) { - return fastApiCharts.stream() - .map(fast -> LibraryCompareResponseDTO.ComparisonChart.builder() - .chartType(fast.getChartType()) - .title(fast.getTitle()) - .data(fast.getData()) - .options(fast.getOptions()) - .build()) - .toList(); - } + private List convertFilters(Library library) { + List searchHistoryLibraries = + searchHistoryLibraryRepository.findByLibraryId(library.getId()); - private List convertBasicComparison( - List fastApiComparison) { - return fastApiComparison.stream() - .map(fast -> LibraryCompareResponseDTO.BasicComparison.builder() - .metric(fast.getMetric()) - .group1Value(fast.getGroup1Value()) - .group2Value(fast.getGroup2Value()) - .build()) - .toList(); + List histories = searchHistoryLibraries.stream() + .map(SearchHistoryLibrary::getHistory) + .toList(); + + List searchFilters = searchFilterRepository.findBySearchHistory(histories); + +// Set filterIds = searchFilters.stream() +// .map(SearchFilter::getId) +// .collect(Collectors.toSet()); + + Set filterIds = new HashSet<>(); + searchFilters.forEach(searchFilter -> filterIds.addAll(searchFilter.getFilters())); + + List filters = filterRepository.findByIds(filterIds); + + Map> grouped = filters.stream() + .collect(Collectors.groupingBy( + Filter::getType, + Collectors.mapping(Filter::getDisplayValue, Collectors.toList()) + )); + + return grouped.entrySet().stream() + .map(entry -> LibraryCompareResponseDTO.Filter.builder() + .key(entry.getKey()) + .values(entry.getValue()) + .build()) + .toList(); } + private void createLibraryPanels(Library library, List panelIds) { List panels = panelRepository.findByIdList(panelIds); @@ -574,6 +517,14 @@ private void createLibraryPanels(Library library, List panelIds) { libraryPanelRepository.saveAll(libraryPanels); } + private LibraryCompareResponseDTO.Insights convertInsights(FastLibraryCompareResponseDTO.Insights insights) { + return LibraryCompareResponseDTO.Insights.builder() + .difference(insights.getDifference()) + .common(insights.getCommon()) + .implication(insights.getImplication()) + .build(); + } + // 라이브러리 생성 결과를 담는 내부 클래스 @lombok.Getter @lombok.AllArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/OnboardingRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/OnboardingRepository.java new file mode 100644 index 0000000..505db03 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/repository/OnboardingRepository.java @@ -0,0 +1,21 @@ +package DiffLens.back_end.domain.members.repository; + +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.entity.Onboarding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface OnboardingRepository extends JpaRepository { + + @Query( + """ + SELECT ob FROM Onboarding ob + WHERE ob.member = :member + """ + ) + Optional findByMember(@Param("member") Member member); + +} diff --git a/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java b/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java index 1dbb949..18423f2 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java +++ b/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java @@ -105,10 +105,11 @@ public class Panel extends BaseEntity { @JdbcTypeCode(SqlTypes.VECTOR) @Column(columnDefinition = "vector(4096)") @Basic(fetch = FetchType.LAZY) + @Transient private float[] embedding = new float[4096]; // float[] 써야한다고 함... @JdbcTypeCode(SqlTypes.ARRAY) - @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + @Column(columnDefinition = "text[]") private List hashTags = new ArrayList<>(); // 연관관계 diff --git a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java index 1206cf4..5d54fce 100644 --- a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java +++ b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java @@ -3,6 +3,7 @@ import DiffLens.back_end.domain.search.dto.SearchRequestDTO; import DiffLens.back_end.domain.search.dto.SearchResponseDTO; import DiffLens.back_end.domain.search.service.interfaces.SearchHistoryService; +import DiffLens.back_end.domain.search.service.interfaces.SearchRecommendService; import DiffLens.back_end.domain.search.service.interfaces.SearchService; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -17,12 +18,14 @@ @RequiredArgsConstructor public class SearchController { - private final SearchService naturalServiceService; + private final SearchService naturalSearchService; + private final SearchService recommendationSearchService; private final SearchService existingSearchService; private final SearchHistoryService searchHistoryService; + private final SearchRecommendService searchRecommendService; @PostMapping - @Operation(summary = "자연어 검색 ( ai 연동 전 )", + @Operation(summary = "자연어 검색 ( 자연어 쿼리 직접 입력 ) ( ai 연동 전 )", description = """ ## 개요 @@ -37,14 +40,29 @@ public class SearchController { """) public ApiResponse naturalLanguage(@RequestBody @Valid SearchRequestDTO.NaturalLanguage request) { - SearchResponseDTO.SearchResult result = naturalServiceService.search(request); + SearchResponseDTO.SearchResult result = naturalSearchService.search(request); return ApiResponse.onSuccess(result); } - @PostMapping("/refine") - @Operation(summary = "기존 검색 결과 기반 재검색 ( 미구현 )", description = "아직 구현 전이지만 아마 자연어 검색과 같은 형태로 반환될 듯 싶습니다.") - public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.ExistingSearchResult request) { - SearchResponseDTO.SearchResult result = new SearchResponseDTO.SearchResult(); // 임시 result + @PostMapping("/recommended/{recommendedId}") + @Operation(summary = "추천 검색어로 검색 ( ai가 추천해준 검색 정보로 검색 ) ( ai X )", + description = """ + + ## 개요 + AI가 추천해준 검색 정보로 검색합니다. + + ## 요청 + - 맞춤 검색 추천 api 호출로 얻은 결과 중 recommendations에 포함된 검색 정보의 id를 recommendedId에 넣어 요청하면 됩니다. + - 검색 정보는 DB가 아닌 캐시에 저장되어 일정 시간이 지나면 올바른 recommendedId로 요청해도 오류가 발생합니다. + - 만료되었다는 응답이 발생하면 '맞춤 검색 추천' api를 다시 호출하거나,\n + 추천 검색어 api에서 응답받은 title 혹은 query를 이용해서 자연어 검색 api를 호출하여 검색해주세요. + + ## 응답 + 자연어 검색과 동일한 형태의 응답을 보냅니다. + + """) + public ApiResponse recommendedSearch(@PathVariable("recommendedId") Long recommendedId) { + SearchResponseDTO.SearchResult result = recommendationSearchService.search(recommendedId); return ApiResponse.onSuccess(result); } @@ -71,10 +89,18 @@ public ApiResponse eachResponses(@PathVariable( } @GetMapping("/recommended") - @Operation(summary = "맞춤 검색 추천 ( 미구현 )", description = "요청 형태 확정 후 구현하겠습니다.") - public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.SearchFilters request) { - return ApiResponse.onSuccess(null); + @Operation(summary = "맞춤 검색 추천 ( ai 연동 전 )", description = "유저 온보딩 정보, 검색기록을 토대로 검색어를 추천합니다.") + public ApiResponse refine() { + SearchResponseDTO.Recommends recommendations = searchRecommendService.getRecommendations(); + return ApiResponse.onSuccess(recommendations); } + @PostMapping("/refine") + @Operation(summary = "기존 검색 결과 기반 재검색 ( 미구현 )", description = "아직 구현 전이지만 아마 자연어 검색과 같은 형태로 반환될 듯 싶습니다.", hidden = true) + public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.ExistingSearchResult request) { + SearchResponseDTO.SearchResult result = new SearchResponseDTO.SearchResult(); // 임시 result + return ApiResponse.onSuccess(result); + } + } diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java index f8f9f59..8a8f04d 100644 --- a/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java +++ b/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java @@ -2,24 +2,31 @@ import DiffLens.back_end.domain.search.dto.ChartDTO; import DiffLens.back_end.domain.search.entity.Chart; +import DiffLens.back_end.domain.search.enums.chart.ChartType; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; @Component -public class ChartDtoConverter implements SearchDtoConverter, List> { +public class ChartDtoConverter implements SearchDtoConverter { @Override - public List requestToDto(Void response, List charts) { - return charts.stream() - .map(this::chartToDto) - .toList(); + public ChartDTO.Graph requestToDto(Void response, Chart chart) { + return ChartDTO.Graph.builder() + .chartId(null) + .reason(chart.getReason()) + .chartType(chart.getChartType().name()) + .title(chart.getTitle()) + .xAxis(chart.getXAxis()) + .yAxis(chart.getYAxis()) + .dataPoints(getDataPoints(chart)) + .build(); } private ChartDTO.Graph chartToDto(Chart chart) { return ChartDTO.Graph.builder() - .chartId("") + .chartId(null) .reason(chart.getReason()) .chartType(chart.getChartType().name()) .title(chart.getTitle()) @@ -30,6 +37,31 @@ private ChartDTO.Graph chartToDto(Chart chart) { } private List getDataPoints(Chart chart) { + if (chart.getChartType() == ChartType.PIE) { + return getPiePoints(chart); + } + return getGraphPoints(chart); + } + + + private List getPiePoints(Chart chart) { + List dataPoints = new ArrayList<>(); + List labels = chart.getLabels(); + List values = chart.getValues(); + + int total = values.stream().mapToInt(Integer::intValue).sum(); + if (total == 0) return dataPoints; + + for (int i = 0; i < labels.size(); i++) { + double percentage = (values.get(i) * 100.0) / total; + int roundedPercentage = (int) Math.round(percentage); + dataPoints.add(getDataPoint(labels.get(i), roundedPercentage)); + } + + return dataPoints; + } + + private List getGraphPoints(Chart chart) { List dataPoints = new ArrayList<>(); List labels = chart.getLabels(); List values = chart.getValues(); diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java index 288b9a1..fcfc82c 100644 --- a/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java +++ b/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java @@ -20,7 +20,7 @@ public SearchResponseDTO.SearchResult.Summary requestToDto(FastNaturalLanguageRe .totalRespondents(panelList.size()) .averageAge(getAgeAvg(panelList)) .dataCaptureDate(getCurrentDate()) - .confidenceLevel(response.getAccuracy().intValue()) + .confidenceLevel(response.getAccuracy() != null ? response.getAccuracy().intValue() : null) .build(); } diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java index cdbf760..868c8b2 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java @@ -3,6 +3,7 @@ import DiffLens.back_end.domain.search.enums.mode.QuestionMode; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -13,6 +14,7 @@ public class SearchRequestDTO { // 자연어 검색 @Getter @Setter + @Builder public static class NaturalLanguage{ @NotBlank private String question; diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java index d564e32..d65d224 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java @@ -27,6 +27,8 @@ public static class SearchResult { @JsonProperty("applied_filters_summary") private List appliedFiltersSummary; + private ChartDTO.Graph pie; + private List charts; // @JsonProperty("panel_data") // 개별 API로 분리 @@ -108,6 +110,25 @@ public static ResponseValues fromPanelDTO(PanelWithRawDataDTO panel, String conc } + // 추천 검색 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Recommends{ + private List recommendations; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Recommend{ + private Long id; + private String title; + private String description; + } + diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java b/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java index 1044464..a3b4862 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java @@ -9,6 +9,7 @@ public enum ChartType { BAR, PIE, + LINE, DOUGHNUT, ; diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java index 05e9e3a..e8527c6 100644 --- a/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java +++ b/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java @@ -3,8 +3,19 @@ import DiffLens.back_end.domain.search.entity.Filter; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Set; public interface FilterRepository extends JpaRepository { + + @Query( + """ + SELECT f from Filter f + WHERE f.id in :ids + """ + ) + List findByIds(@Param("ids") Set ids); + } diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java index 3716149..aaa6820 100644 --- a/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java +++ b/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java @@ -1,8 +1,22 @@ package DiffLens.back_end.domain.search.repository; import DiffLens.back_end.domain.search.entity.SearchFilter; +import DiffLens.back_end.domain.search.entity.SearchHistory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.function.Function; public interface SearchFilterRepository extends JpaRepository { + @Query( + """ + SELECT sf FROM SearchFilter sf + WHERE sf.searchHistory in :histories + """ + ) + List findBySearchHistory(@Param("histories") List searchHistories); + } diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java index df9e2f4..b2fc103 100644 --- a/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java +++ b/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java @@ -1,9 +1,22 @@ package DiffLens.back_end.domain.search.repository; +import DiffLens.back_end.domain.members.entity.Member; import DiffLens.back_end.domain.search.entity.SearchHistory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface SearchHistoryRepository extends JpaRepository { + + @Query( + """ + SELECT sh FROM SearchHistory sh + WHERE sh.member = :member + """ + ) + List findByMember(@Param("member") Member member); + } diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/cache/RedisRecommendationCacheRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/cache/RedisRecommendationCacheRepository.java new file mode 100644 index 0000000..8e4c25a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/repository/cache/RedisRecommendationCacheRepository.java @@ -0,0 +1,43 @@ +package DiffLens.back_end.domain.search.repository.cache; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.function.Supplier; + +/** + * 추천 검색어를 관리하는 redis Repository + */ +@Repository +@RequiredArgsConstructor +public class RedisRecommendationCacheRepository { + + private final RedisTemplate redisTemplate; + private static final Duration TTL = Duration.ofMinutes(10); // 유효기간 + + // 저장 + public void save(String key, Object value) { + redisTemplate.opsForValue().set(key, value, TTL); + } + + @SuppressWarnings("unchecked") + public T findByKey(String key){ + return (T) redisTemplate.opsForValue().get(key); + } + + public T findOrElse(String key, Supplier supplier) { + T value = findByKey(key); + if (value == null) { + value = supplier.get(); + save(key, value); + } + return value; + } + + public void delete(String key) { + redisTemplate.delete(key); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java index dd2f72f..f3c0c19 100644 --- a/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java @@ -4,6 +4,7 @@ import DiffLens.back_end.domain.members.service.auth.CurrentUserService; import DiffLens.back_end.domain.panel.repository.PanelRepository; import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.domain.search.enums.chart.ChartType; import DiffLens.back_end.global.fastapi.FastApiService; import DiffLens.back_end.global.fastapi.dto.request.FastNaturalLanguageRequestDTO; import DiffLens.back_end.global.fastapi.dto.response.FastNaturalLanguageResponseDTO; @@ -38,7 +39,7 @@ public class NaturalSearchService implements SearchService> summaryConverter; private final SearchDtoConverter, SearchHistory> filterConverter; - private final SearchDtoConverter, List> chartConverter; + private final SearchDtoConverter chartConverter; // private final SearchDtoConverter> panelResponseConverter; private final CurrentUserService currentUserService; @@ -82,7 +83,14 @@ public SearchResponseDTO.SearchResult search(SearchRequestDTO.NaturalLanguage re charts.forEach(searchHistory::addChart); // 연관관계 편의 메서드 호출 // 차트 변환 - List graphs = chartConverter.requestToDto(null, charts); + Chart pieChart = charts.stream().filter(chart -> chart.getChartType() == ChartType.PIE) // 상단 차트 ( PIE 하나 )를 변환하여 생성 + .findFirst().orElse(null); + ChartDTO.Graph pie = chartConverter.requestToDto(null, pieChart); + + List graphs = charts.stream() // PIE를 제외한 차트를 변환하여 List 생성 + .filter(chart -> chart.getChartType() != ChartType.PIE) + .map(chart -> chartConverter.requestToDto(null, chart)) + .toList(); // 개별 응답 데이터 처리 및 반환 // SearchPanelDTO.PanelData panelData = panelResponseConverter.requestToDto(response, foundPanels); @@ -91,6 +99,7 @@ public SearchResponseDTO.SearchResult search(SearchRequestDTO.NaturalLanguage re .searchId(searchHistory.getId()) .summary(summary) .appliedFiltersSummary(appliedFiltersSummary) + .pie(pie) .charts(graphs) // .panelData(panelData) // 개별 API로 분리 .build(); diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/RecommendedSearchService.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/RecommendedSearchService.java new file mode 100644 index 0000000..e0c28ea --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/RecommendedSearchService.java @@ -0,0 +1,60 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; +import DiffLens.back_end.domain.search.dto.SearchRequestDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.enums.mode.QuestionMode; +import DiffLens.back_end.domain.search.service.interfaces.SearchCacheService; +import DiffLens.back_end.domain.search.service.interfaces.SearchService; +import DiffLens.back_end.global.fastapi.dto.response.FastHomeResponseDTO; +import DiffLens.back_end.global.responses.code.status.error.SearchStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; + +/** + * ai가 추천해준 검색어로 검색합니다. + */ +@Service +@RequiredArgsConstructor +public class RecommendedSearchService implements SearchService { + + private final CurrentUserService currentUserService; + private final SearchService naturalSearchService; + private final SearchCacheService recommendCacheService; + + @Override + public SearchResponseDTO.SearchResult search(Long recommendedId) { + + // 0. 현재 유저 조회 + Member member = currentUserService.getCurrentUser(); + + // 1. 캐시에 있으면 그거로 SearchRequestDTO.NaturalLanguage 객체 만듦 + FastHomeResponseDTO.Data cacheInfo = recommendCacheService.getCacheInfo(member); + if (cacheInfo == null) { // + throw new ErrorHandler(SearchStatus.RECOMMENDED_EXPIRED); // 멤버에 해당하는 추천 검색어 캐시가 만료된 경우 + } + + // 1-1. 캐시에서 조회한 데이터 중 recommendedId에 해당하는 Recommendation + FastHomeResponseDTO.Recommendation recommendation = cacheInfo.getRecommendations().stream() + .filter(r -> r.getId().equals(recommendedId)) + .findFirst() + .orElseThrow(() -> new ErrorHandler(SearchStatus.RECOMMENDED_SEARCH_NOT_FOUND)); // 멤버에 해당하는 캐시 정보는 있지만 recommendedId가 잘못된 경우 + + // 2. SearchRequestDTO.NaturalLanguage 이거로 자연어 검색 호출 - naturalSearchService + + // 2-1. Recommendation -> SearchRequestDTO.NaturalLanguage 변환 + SearchRequestDTO.NaturalLanguage naturalRequestDTO = SearchRequestDTO.NaturalLanguage.builder() + .question(recommendation.getQuery()) + .mode(QuestionMode.STRICT) + .filters(new ArrayList()) // 일단 필터 없이... + .build(); + + // 2-1. SearchRequestDTO.NaturalLanguage로 기존 자연어 검색 서비스 메서드 호출 -> 반환 + return naturalSearchService.search(naturalRequestDTO); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendCacheService.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendCacheService.java new file mode 100644 index 0000000..9979b0e --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendCacheService.java @@ -0,0 +1,41 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.search.repository.cache.RedisRecommendationCacheRepository; +import DiffLens.back_end.domain.search.service.interfaces.SearchCacheService; +import DiffLens.back_end.global.fastapi.dto.response.FastHomeResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SearchRecommendCacheService implements SearchCacheService { + + // 캐시 정보를 관리하는 repository + private final RedisRecommendationCacheRepository cacheRepository; + + // 추천 정보를 저장하는 키의 접두사( Prefix ) + private static final String CACHE_KEY_PREFIX = "search:recommend:"; // search:recommend:{memberId} 형식으로 key 지정할 예정 + + // 저장되어있는 추천정보를 꺼냄 + @Override + public FastHomeResponseDTO.Data getCacheInfo(Member member) { + + // 캐시에서 조회할 키를 생성 + String key = getKey(member); + + // key로 캐시 조회 및 반환. null일 수 있음 + return cacheRepository.findByKey(key); + } + + // 추천정보를 캐시에 저장함 + @Override + public void saveCacheInfo(Member member, FastHomeResponseDTO.Data cacheInfo) { + cacheRepository.save(getKey(member), cacheInfo); + } + + private String getKey(Member member) { + return CACHE_KEY_PREFIX + member.getId(); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendServiceImpl.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendServiceImpl.java new file mode 100644 index 0000000..954ecb1 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchRecommendServiceImpl.java @@ -0,0 +1,129 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.entity.Onboarding; +import DiffLens.back_end.domain.members.enums.Industry; +import DiffLens.back_end.domain.members.enums.Job; +import DiffLens.back_end.domain.members.repository.OnboardingRepository; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.repository.SearchHistoryRepository; +import DiffLens.back_end.domain.search.service.interfaces.SearchCacheService; +import DiffLens.back_end.domain.search.service.interfaces.SearchRecommendService; +import DiffLens.back_end.global.fastapi.FastApiService; +import DiffLens.back_end.global.fastapi.dto.request.FastHomeRequestDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastHomeResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchRecommendServiceImpl implements SearchRecommendService { + + private final FastApiService fastApiService; + private final CurrentUserService currentUserService; + private final SearchCacheService recommendCacheService; + + private final OnboardingRepository onboardingRepository; + private final SearchHistoryRepository searchHistoryRepository; + + /** + * + * user의 온보딩, 검색기록을 기반으로 추천 검색 정보를 조회합니다. + * 홈화면에서 조회하는 기능 특성상 다량 호출이 예상되어 불필요한 ai 로직 호출을 줄이기 위해 redis 통한 캐싱 처리를 합니다. + * + * redis에 member에 해당하는 추천 캐시 정보가 있다면 캐시에서 데이터를 조회하여 반환하고, + * 캐시에 없다면 ai 로직을 호출하여 클라이언트에 반환합니다. + * + * @return 추천 검색 정보 - SearchResponseDTO.Recommends + */ + @Override + public SearchResponseDTO.Recommends getRecommendations() { + + // 0. 현재 로그인한 유저 조회 + Member member = currentUserService.getCurrentUser(); + + // 1. 캐시에 존재하면 캐시에서 꺼내서 반환 + FastHomeResponseDTO.Data cacheInfo = recommendCacheService.getCacheInfo(member); + if (cacheInfo != null) { // redis에 값이 있다면 ai 로직을 호출하지 않고 바로 return + log.info("[API 호출중] 검색 추천 정보를 캐시에서 조회"); + return fastDtoToResponseList(cacheInfo); + } + + log.info("[API 호출중] 검색 추천 정보를 조회하기 위해 AI 로직 호출"); + + // 2. 온보딩 정보 조회 + Onboarding onboarding = onboardingRepository.findByMember(member) + .orElse(Onboarding.builder() + .job(Job.ETC_FREELANCER) + .industry(Industry.ETC) + .build() + ); // 없으면 그냥 기타 정보 들어있는 객체를 담아서 보냄 + + // 2-1. 온보딩 정보에서 직무(Job)와 직종(Industry) 추출 => HomeRecommendOnboarding 생성 + + FastHomeRequestDTO.HomeRecommendOnboarding onboardingResult = FastHomeRequestDTO.HomeRecommendOnboarding.builder() + .job(onboarding.getJob().getKrValue()) + .industry(onboarding.getIndustry().getKrValue()) + .build(); + + // 3. 검색기록 조회 + List searchHistoryList = searchHistoryRepository.findByMember(member); + + // 3-1. 검색기록 목록에서 검색내용 ( content ) 만 추출 + List searchContentList = searchHistoryList.stream() + .map(SearchHistory::getContent) + .toList(); + + // 4. 위에서 구한 것들로 요청 DTO 생성 + FastHomeRequestDTO.HomeRecommendRequest fastRequestDTO = FastHomeRequestDTO.HomeRecommendRequest.builder() + .memberId(member.getId()) + .onboarding(onboardingResult) + .recentSearches(searchContentList) + .build(); + + // 5. fast api 요청 후 받은 응답결과를 저장 + FastHomeResponseDTO.HomeRecommend fastResponse = fastApiService.recommend(fastRequestDTO); + + // 6. 클라이언트에게 보낼 DTO 생성 후 + SearchResponseDTO.Recommends result = fastDtoToResponseList(fastResponse); + recommendCacheService.saveCacheInfo(member, fastResponse.getData()); // 이후에 조회할 때 캐시(Redis)에서 조회하기 위해 캐시에 저장 + + // 7. 반환 + return result; + } + + // Fast api에서 받은 응답 객체를 클라이언트에게 응답보낼 dto로 변환 + // FastHomeResponseDTO.HomeRecommend -> SearchResponseDTO.Recommends + private SearchResponseDTO.Recommends fastDtoToResponseList(FastHomeResponseDTO.HomeRecommend fastResponse) { + // 응답받은 객체에서 추천 객체만 뽑아냄 + return fastDtoToResponseList(fastResponse.getData()); + } + + // Fast api에서 받은 응답 객체를 클라이언트에게 응답보낼 dto로 변환 + // FastHomeResponseDTO.Data -> SearchResponseDTO.Recommends + private SearchResponseDTO.Recommends fastDtoToResponseList(FastHomeResponseDTO.Data recommedationData){ + + // 응답받은 객체에서 추천 객체만 뽑아냄 + List recommendations = recommedationData.getRecommendations(); + + // 추천 객체제를 순회하며 Recommend( 클라이언트 DTO에 담기는 ) 목록 생성 + List recommendList = recommendations.stream() + .map(recommendation -> SearchResponseDTO.Recommend.builder() + .id(recommendation.getId()) + .title(recommendation.getTitle()) + .description(recommendation.getTitle()) + .build() + ).toList(); + + return SearchResponseDTO.Recommends.builder() + .recommendations(recommendList) + .build(); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchCacheService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchCacheService.java new file mode 100644 index 0000000..344534a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchCacheService.java @@ -0,0 +1,11 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.members.entity.Member; + +public interface SearchCacheService { + + T getCacheInfo(Member member); + + void saveCacheInfo(Member member, T cacheInfo); + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchRecommendService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchRecommendService.java new file mode 100644 index 0000000..e5099cd --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchRecommendService.java @@ -0,0 +1,7 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; + +public interface SearchRecommendService { + SearchResponseDTO.Recommends getRecommendations(); +} diff --git a/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java b/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java index 63f42ba..43e0b69 100644 --- a/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java +++ b/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java @@ -15,12 +15,12 @@ @AllArgsConstructor public enum FastApiRequestType { - NATURAL_SEARCH("/search", FastNaturalLanguageRequestDTO.NaturalSearch.class, + NATURAL_SEARCH("/ai/search", FastNaturalLanguageRequestDTO.NaturalSearch.class, FastNaturalLanguageResponseDTO.NaturalSearch.class), - RE_SEARCH("/re-search", FastReSearchRequestDTO.ReSearch.class, FastReSearchResponseDTO.ReSearch.class), - RECOMMENDATIONS("/recommendations", FastHomeRequestDTO.HomeRecommendRequest.class, + RE_SEARCH("/ai/re-search", FastReSearchRequestDTO.ReSearch.class, FastReSearchResponseDTO.ReSearch.class), + RECOMMENDATIONS("/ai/recommendations", FastHomeRequestDTO.HomeRecommendRequest.class, FastHomeResponseDTO.HomeRecommend.class), - COMPARE("/compare", FastLibraryRequestDTO.LibraryCompare.class, + COMPARE("/ai/compare", FastLibraryRequestDTO.LibraryCompare.class, FastLibraryCompareResponseDTO.CompareResult.class), // REFINE_SEARCH("/search/refine", // FastNaturalSearchResponseDTO.SearchResult.class), diff --git a/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java b/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java index b0e4307..fade724 100644 --- a/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java +++ b/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java @@ -1,7 +1,9 @@ package DiffLens.back_end.global.fastapi; +import DiffLens.back_end.global.fastapi.dto.request.FastHomeRequestDTO; import DiffLens.back_end.global.fastapi.dto.request.FastNaturalLanguageRequestDTO; import DiffLens.back_end.global.fastapi.dto.request.FastLibraryRequestDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastHomeResponseDTO; import DiffLens.back_end.global.fastapi.dto.response.FastNaturalLanguageResponseDTO; import DiffLens.back_end.global.fastapi.dto.response.FastLibraryCompareResponseDTO; import lombok.RequiredArgsConstructor; @@ -27,4 +29,9 @@ public FastLibraryCompareResponseDTO.CompareResult compareLibraries(FastLibraryR return fastApiClient.sendRequest(FastApiRequestType.COMPARE, request); } + // 추천검색 + public FastHomeResponseDTO.HomeRecommend recommend(FastHomeRequestDTO.HomeRecommendRequest request) { + return fastApiClient.sendRequest(FastApiRequestType.RECOMMENDATIONS, request); + } + } diff --git a/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastLibraryCompareResponseDTO.java b/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastLibraryCompareResponseDTO.java index bb2e73a..2cfadfe 100644 --- a/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastLibraryCompareResponseDTO.java +++ b/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastLibraryCompareResponseDTO.java @@ -19,8 +19,9 @@ public class FastLibraryCompareResponseDTO { public static class CompareResult { private List keyCharacteristics; - private List comparisonCharts; +// private List comparisonCharts; private List basicComparison; + private Insights aiInsights; } @Getter @@ -34,15 +35,15 @@ public static class KeyCharacteristic { private Integer difference; } - @Getter - @Setter - @ToString - public static class ComparisonChart { - private String chartType; - private String title; - private Map data; - private Map options; - } +// @Getter +// @Setter +// @ToString +// public static class ComparisonChart { +// private String chartType; +// private String title; +// private Map data; +// private Map options; +// } @Getter @Setter @@ -52,4 +53,13 @@ public static class BasicComparison { private String group1Value; private String group2Value; } + + @Getter + @Setter + public static class Insights { + private String difference; + private String common; + private String implication; + } + } diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java index 088401e..665a5a1 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java @@ -14,21 +14,29 @@ public enum SearchStatus implements BaseErrorCode { PANEL_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH405", "패널을 찾을 수 없습니다."), + RECOMMENDED_SEARCH_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH406", "잘못된 recommendId 입니다."), + RECOMMENDED_EXPIRED(HttpStatus.BAD_REQUEST, "SEARCH407", "만료된 추천 검색어입니다."), + INVALID_COLUMN(HttpStatus.INTERNAL_SERVER_ERROR, "SEARCH501", "Panel의 column을 찾을 수 없습니다."), + ; private final HttpStatus httpStatus; private final String code; private final String message; - @Override public ErrorReasonDto getReason() { - return null; + return ErrorReasonDto.builder().message(message).code(code).isSuccess(false).build(); } @Override public ErrorReasonDto getReasonHttpStatus() { - return null; + return ErrorReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); } }