diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 176ae35..f477ea8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -30,7 +30,8 @@ public class SecurityPath { "/api/posts", "/api/posts/*", "/api/search/history", - "/api/search/history/*" + "/api/search/history/*", + "/api/mongo/services/matched" }; // hasRole("ADMIN") diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java index 85cb793..9695bc5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java @@ -1,6 +1,5 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; -import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; @@ -8,12 +7,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; -import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchService; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchHandler; import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; import java.util.List; @@ -23,7 +21,7 @@ @RequestMapping("/api/mongo/services") @RequiredArgsConstructor public class SearchInfoController { - private final ServiceSearchService searchService; + private final ServiceSearchHandler searchService; private final AuthenticateUser authenticateUser; // 검색 API (로그인/비로그인 통합) diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java index 704344f..fe59802 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -1,15 +1,43 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceMatchedHandler; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Validated @RestController -@RequestMapping("/api/services/matched") +@RequestMapping("/api/mongo/services/matched") @RequiredArgsConstructor public class ServiceMatchedController { + private final ServiceMatchedHandler serviceMatchedHandler; + + /** + * 사용자 맞춤 공공서비스 추천 API + * 사용자 프로필 및 검색 기록 기반으로 개인화된 서비스 목록 추천 + */ + @GetMapping + public ResponseEntity> getMatchedServices( + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(20) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 사용자 맞춤 추천 서비스 조회 + List matchedServices = + serviceMatchedHandler.getPersonalizedServices(userDetails.getId(), size); + return ResponseEntity.ok(matchedServices); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java new file mode 100644 index 0000000..e0c7241 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java @@ -0,0 +1,345 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MatchedServiceClient { + private final MongoTemplate mongoTemplate; + private static final String INDEX_NAME = "searchIndex"; + private static final String COLLECTION_NAME = "service_info"; + + private static final String PROJECT_STAGE = """ + { + $project: { + publicServiceId: 1, + serviceName: 1, + summaryPurpose: 1, + serviceCategory: 1, + specialGroup: 1, + familyType: 1, + occupations: 1, + businessTypes: 1, + targetGenderMale: 1, + targetGenderFemale: 1, + targetAgeStart: 1, + targetAgeEnd: 1, + incomeLevel: 1, + matchCount: 1, + score: {$meta: 'searchScore'} + } + }"""; + + /** + * 사용자 맞춤 공공서비스 추천 + */ + public ServiceSearchResultDto getMatchedServices( + List keywords, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob, + int size) { + + // 키워드가 없는 경우 빈 결과 반환 + if (keywords == null || keywords.isEmpty()) { + return ServiceSearchResultDto.of(Collections.emptyList(),0L, PageRequest.of(0, size)); + } + + String searchQuery = buildSearchQuery( + keywords, + userGender, + userAge, + userIncomeLevel, + userJob + ); + + // 매칭된 키워드 수를 계산하는 스테이지 + String matchCountStage = buildMatchCountStage(keywords); + + // 매칭된 키워드가 있는 것만 필터링 + String filterStage = "{$match: {matchCount: {$gt: 0}}}"; + + // 매칭 수와 검색 점수로 정렬 + String sortStage = "{$sort: {matchCount: -1, score: -1}}"; + + // 결과 제한 + String limitStage = String.format("{$limit: %d}", size); + + AggregationOperation searchOperation = context -> Document.parse(searchQuery); + AggregationOperation matchCountOperation = context -> Document.parse(matchCountStage); + AggregationOperation filterOperation = context -> Document.parse(filterStage); + AggregationOperation sortOperation = context -> Document.parse(sortStage); + AggregationOperation projectOperation = context -> Document.parse(PROJECT_STAGE); + AggregationOperation limitOperation = context -> Document.parse(limitStage); + + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation( + searchOperation, + matchCountOperation, + filterOperation, + sortOperation, + projectOperation, + limitOperation + ), + COLLECTION_NAME, + Document.class + ); + + return processResults(results, size); + } + + /** + * 검색 쿼리 생성 + */ + private String buildSearchQuery( + List keywords, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + + // should 조건 (가중치가 적용된 키워드 조건) + List shouldClauses = createKeywordMatchClauses(keywords); + + // must 조건 (사용자 조건 필터링) + String mustClause = buildUserMustClause(userGender, userAge); + + // 직업 및 소득 관련 should 조건 추가 + if (StringUtils.hasText(userJob)) { + shouldClauses.add(createSearchClause("occupations", userJob, 3.0f)); + shouldClauses.add(createSearchClause("businessTypes", userJob, 3.0f)); + } + + if (StringUtils.hasText(userIncomeLevel)) { + shouldClauses.add(createSearchClause("incomeLevel", userIncomeLevel, 2.8f)); + shouldClauses.add(createSearchClause("incomeLevel", "ANY", 1.0f)); + // 소득 수준 범위에 따른 가중치 추가 + addIncomeLevelRangeBoosts(shouldClauses, userIncomeLevel); + } + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + String compoundQuery = """ + compound: { + should: %s%s + } + """.formatted(shouldClausesStr, mustClause); + + return """ + { + $search: { + index: '%s', + %s + } + }""".formatted(INDEX_NAME, compoundQuery); + } + + /** + * 키워드 기반 검색 조건 생성 (단일 가중치 적용) + */ + private List createKeywordMatchClauses(List keywords) { + List clauses = new ArrayList<>(); + + // 모든 키워드에 동일한 가중치 부여 + for (String keyword : keywords) { + if (StringUtils.hasText(keyword)) { + // 서비스명 검색 + clauses.add(createSearchClause("serviceName", keyword, 5.0f)); + // 요약 검색 + clauses.add(createSearchClause("summaryPurpose", keyword, 4.0f)); + // 서비스 분야 검색 + clauses.add(createSearchClause("serviceCategory", keyword, 4.5f)); + // 특수그룹 검색 + clauses.add(createSearchClause("specialGroup", keyword, 4.0f)); + // 가족유형 검색 + clauses.add(createSearchClause("familyType", keyword, 4.0f)); + } + } + + return clauses; + } + + /** + * 검색 조건 생성 헬퍼 메서드 + */ + private String createSearchClause(String path, String query, float boost) { + return """ + {text: { + query: '%s', + path: '%s', + score: {boost: {value: %.1f}} + }}""".formatted(query, path, boost); + } + + /** + * 소득수준 범위에 따른 가중치 부여 + */ + private void addIncomeLevelRangeBoosts(List clauses, String userIncomeLevel) { + switch (userIncomeLevel) { + case "HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_HIGH", 2.5f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 1.5f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.0f)); + break; + case "MIDDLE_HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.5f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f)); + break; + case "MIDDLE": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f)); + break; + case "MIDDLE_LOW": + clauses.add(createSearchClause("incomeLevel", "LOW", 2.0f)); + break; + default: + break; + } + } + + /** + * 사용자 기본 조건(성별, 나이) 필터링 + */ + private String buildUserMustClause(String userGender, Integer userAge) { + List mustClauses = new ArrayList<>(); + + // 성별 필수 조건 + if (StringUtils.hasText(userGender)) { + String genderField = "MALE".equalsIgnoreCase(userGender) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } + ] + } + } + """.formatted(genderField, genderField)); + } + + // 나이 필수 조건 + if (userAge != null) { + int age = userAge; + + // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeStart"}}] + } + }, + { + range: {path: "targetAgeStart", lte: %d} + } + ] + } + } + """.formatted(age)); + + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeEnd"}}] + } + }, + { + range: {path: "targetAgeEnd", gte: %d} + } + ] + } + } + """.formatted(age)); + } + + // must 조건이 없으면 빈 문자열 반환 + if (mustClauses.isEmpty()) { + return ""; + } + + // must 조건이 있으면 문자열 형식으로 반환 + return ", must: [" + String.join(",", mustClauses) + "]"; + } + + /** + * 매칭된 키워드 수 계산 + */ + private String buildMatchCountStage(List keywords) { + if (keywords == null || keywords.isEmpty()) { + return "{$addFields: {matchCount: 0}}"; + } + + String keywordArray = keywords.stream() + .filter(StringUtils::hasText) + .map(keyword -> "\"" + keyword + "\"") + .collect(Collectors.joining(", ")); + + return """ + {$addFields: { + matchCount: { + $add: [ + {$size: {$ifNull: [{$setIntersection: ["$specialGroup", [%s]]}, []]}}, + {$size: {$ifNull: [{$setIntersection: ["$familyType", [%s]]}, []]}}, + {$cond: [{$in: ["$serviceCategory", [%s]]}, 1, 0]} + ] + } + }} + """.formatted(keywordArray, keywordArray, keywordArray); + } + + /** + * 검색 결과 처리 + */ + private ServiceSearchResultDto processResults(AggregationResults results, int size) { + List resultDocs = results.getMappedResults(); + if (resultDocs.isEmpty()) { + return ServiceSearchResultDto.of(Collections.emptyList(),0L, PageRequest.of(0, size)); + } + + List searchResults = resultDocs.stream() + .map(doc -> mongoTemplate.getConverter().read(ServiceInfo.class, doc)) + .collect(Collectors.toList()); + + return ServiceSearchResultDto.of( + searchResults, + searchResults.size(), + PageRequest.of(0, size) + ); + } +} + diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java new file mode 100644 index 0000000..b88b15f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java @@ -0,0 +1,91 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.mapper.mongodb.ServiceInfoMapper; +import com.hyetaekon.hyetaekon.publicservice.repository.mongodb.MatchedServiceClient; +import com.hyetaekon.hyetaekon.searchHistory.Service.SearchHistoryService; +import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.repository.UserInterestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ServiceMatchedHandler { + private final MatchedServiceClient matchedServiceClient; + private final UserRepository userRepository; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + private final SearchHistoryService searchHistoryService; + private final ServiceSearchHandler serviceSearchHandler; + + /** + * 사용자 맞춤 공공서비스 추천 - 사용자 정보 및 검색 기록 기반 + */ + @Cacheable(value = "matchedServices", key = "#userId", unless = "#result.isEmpty()") + public List getPersonalizedServices(Long userId, int size) { + // 사용자 정보 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID입니다.")); + + // 사용자 키워드 조회 (관심사 + 검색 기록) - 중복 제거 및 필터링 + List userKeywords = Stream.concat( + userInterestRepository.findByUserId(userId).stream() + .map(UserInterest::getInterest), + searchHistoryService.getUserSearchHistories(userId).stream() + .map(SearchHistoryDto::getSearchTerm) + ) + .filter(StringUtils::hasText) + .distinct() + .toList(); + + // 키워드가 없는 경우 빈 목록 반환 + if (userKeywords.isEmpty()) { + return Collections.emptyList(); + } + + // 사용자 소득 수준 추정 + String userIncomeLevel = incomeEstimationHandler.determineIncomeLevelFromJob(user.getJob()); + + // 사용자 나이 계산 + Integer userAge = serviceSearchHandler.calculateAge(user.getBirthAt()); + + // MongoDB 클라이언트를 통한 맞춤 서비스 조회 + ServiceSearchResultDto searchResult = matchedServiceClient.getMatchedServices( + userKeywords, + user.getGender(), + userAge, + userIncomeLevel, + user.getJob(), + size + ); + + // DTO 변환 및 북마크 정보 설정 + return searchResult.getResults().stream() + .map(serviceInfo -> { + PublicServiceListResponseDto dto = serviceInfoMapper.toDto(serviceInfo); + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId( + userId, serviceInfo.getPublicServiceId())); + return dto; + }) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java similarity index 98% rename from src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java rename to src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java index 241e093..dbaea0f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor -public class ServiceSearchService { +public class ServiceSearchHandler { private final ServiceSearchClient serviceSearchClient; private final BookmarkRepository bookmarkRepository; private final ServiceInfoMapper serviceInfoMapper; @@ -116,7 +116,7 @@ private Page convertToPageResponse( } // 나이 계산 헬퍼 메서드 - private Integer calculateAge(LocalDate birthDate) { + public Integer calculateAge(LocalDate birthDate) { if (birthDate == null) return null; return Period.between(birthDate, LocalDate.now()).getYears(); }