diff --git a/build.gradle b/build.gradle index d19b993..7d40ded 100644 --- a/build.gradle +++ b/build.gradle @@ -66,9 +66,13 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' // MongoDB - /*implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - implementation 'org.mongodb:mongodb-driver-sync' - implementation 'org.mongodb:mongodb-driver-core'*/ + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' +// implementation 'org.mongodb:mongodb-driver-sync' +// implementation 'org.mongodb:mongodb-driver-core' + + // Caffeine + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java b/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java index 2c2faad..8578902 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java +++ b/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; @EnableJpaAuditing @SpringBootApplication @EnableScheduling +@EnableMongoRepositories public class HyetaekonApplication { public static void main(String[] args) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java deleted file mode 100644 index 907ebcf..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.hyetaekon.hyetaekon.UserInterest.repository; - -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface UserInterestRepository extends JpaRepository { - -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java index cee5f98..e7b6c34 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java @@ -19,7 +19,7 @@ public class BookMarkController { // 북마크 추가 @PostMapping public ResponseEntity addBookmark( - @PathVariable("serviceId") Long serviceId, + @PathVariable("serviceId") String serviceId, @AuthenticationPrincipal CustomUserDetails customUserDetails ) { bookmarkService.addBookmark(serviceId, customUserDetails.getId()); @@ -29,7 +29,7 @@ public ResponseEntity addBookmark( // 북마크 제거 @DeleteMapping public ResponseEntity removeBookmark( - @PathVariable("serviceId") Long serviceId, + @PathVariable("serviceId") String serviceId, @AuthenticationPrincipal CustomUserDetails customUserDetails ) { bookmarkService.removeBookmark(serviceId, customUserDetails.getId()); diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java index f43e20b..790f102 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java @@ -11,9 +11,9 @@ @Repository public interface BookmarkRepository extends JpaRepository { - boolean existsByUserIdAndPublicServiceId(Long userId, Long serviceId); + boolean existsByUserIdAndPublicServiceId(Long userId, String serviceId); - Optional findByUserIdAndPublicServiceId(Long userId, Long serviceId); + Optional findByUserIdAndPublicServiceId(Long userId, String serviceId); /*@Query("SELECT b FROM Bookmark b " + "JOIN FETCH b.publicService ps " + diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java index 41aaad6..7e38739 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java @@ -5,6 +5,7 @@ import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; +import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import jakarta.transaction.Transactional; @@ -20,8 +21,9 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final UserRepository userRepository; private final PublicServiceRepository publicServiceRepository; + private final PublicServiceHandler publicServiceHandler; - public void addBookmark(Long serviceId, Long userId) { + public void addBookmark(String serviceId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(BOOKMARK_USER_NOT_FOUND)); @@ -42,10 +44,11 @@ public void addBookmark(Long serviceId, Long userId) { // 북마크 수 증가 publicService.increaseBookmarkCount(); + publicServiceRepository.save(publicService); } @Transactional - public void removeBookmark(Long serviceId, Long userId) { + public void removeBookmark(String serviceId, Long userId) { Bookmark bookmark = bookmarkRepository.findByUserIdAndPublicServiceId(userId, serviceId) .orElseThrow(() -> new GlobalException(BOOKMARK_NOT_FOUND)); @@ -54,5 +57,6 @@ public void removeBookmark(Long serviceId, Long userId) { // 북마크 수 감소 PublicService publicService = bookmark.getPublicService(); publicService.decreaseBookmarkCount(); + publicServiceRepository.save(publicService); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java new file mode 100644 index 0000000..be25a93 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java @@ -0,0 +1,35 @@ +package com.hyetaekon.hyetaekon.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.CacheType; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java index a8fc1ca..719bb1e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -49,8 +50,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth + .requestMatchers("/api/posts/type", "/api/posts/type/**", "/api/posts/search").permitAll() .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") - .requestMatchers(SecurityPath.USER_ENDPOINTS).hasRole("USER") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() .anyRequest().permitAll() ); @@ -72,8 +74,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration(); configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 - configuration.addAllowedOrigin("https://hyetaek-on.site"); // 혜택온 도메인 - configuration.addAllowedOrigin("https://www.hyetaek-on.site"); + configuration.addAllowedOrigin("https://hyetaek-on.co.kr"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.hyetaek-on.co.kr"); configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 configuration.addAllowedHeader("*"); // 모든 헤더 허용 configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); 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 2d23372..3708117 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -11,26 +11,33 @@ public class SecurityPath { "/api/users/check-duplicate", "/", "/api/services", + "/api/services/popular", "/api/services/category/*", "/api/services/detail/*", - "/api/public-data/serviceList/test" + "/api/public-data/serviceList/test", + "/api/mongo/services/search", + "/api/mongo/services/search/autocomplete" }; - // hasRole("USER") public static final String[] USER_ENDPOINTS = { - "/api/users/me/**", "/api/users/me", + "/api/users/me/**", "/api/logout", - "/api/services/popular", "/api/services/*/bookmark", "/api/interests", - "/api/interests/me" + "/api/interests/me", + "/api/posts", + "/api/posts/*", + "/api/search/history", + "/api/search/history/*", + "/api/mongo/services/matched", + "/api/users/reports" }; // hasRole("ADMIN") public static final String[] ADMIN_ENDPOINTS = { - "/api/admin/users/**", + "/api/admin/**", "/api/public-data/serviceDetailList", "/api/public-data/supportConditionsList", "/api/public-data/serviceList" diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java new file mode 100644 index 0000000..2f2e137 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.common.config.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories(basePackages = "com.hyetaekon.hyetaekon.publicservice.repository.redis") +public class SearchHistoryConfig { + + @Bean + public RedisTemplate searchHistoryRedisTemplate(RedisTemplate redisTemplate) { + // 키와 값의 직렬화 방식 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + + return redisTemplate; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java b/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java index 1ec1b35..ed9915f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java @@ -6,7 +6,9 @@ import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Converter(autoApply = true) // autoApply를 true로 설정하면 @Convert없이 해당 타입에 대해 자동으로 변환 public class ServiceCategoryConverter implements AttributeConverter { @@ -17,12 +19,20 @@ public String convertToDatabaseColumn(ServiceCategory serviceCategory) { @Override public ServiceCategory convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + for (ServiceCategory serviceCategory : ServiceCategory.values()) { if (serviceCategory.getType().equals(dbData)) { return serviceCategory; } } - throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + // throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + + // 일치하는 카테고리가 없을 경우 로그를 남기고 기본값 반환 + log.warn("Unknown service category found: '{}'. Using 'OTHER' category instead.", dbData); + return ServiceCategory.OTHER; } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java index 215ae9a..e54102c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -16,8 +16,12 @@ public enum ErrorCode { INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 사용자 이름 또는 비밀번호입니다."), INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 비밀 키입니다."), DELETE_USER_DENIED(HttpStatus.FORBIDDEN, "AUTH-008", "회원 탈퇴가 거부되었습니다."), - ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-009", "권한 정보가 없습니다."), - BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-010", "사용할 수 없는 액세스 토큰입니다."), + CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "AUTH-009","자기 자신을 신고할 수 없습니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-010", "권한 정보가 없습니다."), + BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-011", "사용할 수 없는 액세스 토큰입니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH-012", "해당 신고 내역을 찾을 수 없습니다."), + REPORT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "AUTH-013", "이미 처리된 신고입니다."), + INVALID_REPORT_REQUEST(HttpStatus.BAD_REQUEST, "AUTH-014","잘못된 신고 요청입니다."), // 계정 관련 DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), @@ -40,7 +44,10 @@ public enum ErrorCode { // 좋아요 RECOMMEND_ALREADY_EXISTS(HttpStatus.CONFLICT, "RECOMMEND-001", "이미 좋아요를 누른 게시글입니다."), RECOMMEND_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND-002", "좋아요 정보를 찾을 수 없습니다."), - POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST-001", "해당 게시글을 찾을 수 없습니다."), + RECOMMEND_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND-003", "추천한 유저를 찾을 수 없습니다."), + + // 게시글 + POST_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND,"POST-001", "해당 아이디의 게시글을 찾을 수 없습니다"), // 관심사 선택 제한 INTEREST_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "INTEREST-001", "관심사는 최대 6개까지만 등록 가능합니다."), @@ -48,7 +55,8 @@ public enum ErrorCode { // 공공서비스 // 유효 JACODE 확인 - INVALID_ENUM_CODE(HttpStatus.BAD_REQUEST, "ENUM-001", "유효하지 않은 코드 값입니다."), + INVALID_ENUM_CODE(HttpStatus.BAD_REQUEST, "SERVICE-001", "유효하지 않은 코드 값입니다."), + INCOMPLETE_SERVICE_DETAIL(HttpStatus.BAD_REQUEST, "SERVICE-002","서비스 상세 정보가 불완전합니다."), SERVICE_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "SERVICE-001", "해당 서비스 분야를 찾을 수 없습니다."), SERVICE_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND,"SERVICE-002", "해당 아이디의 서비스를 찾을 수 없습니다"), diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java index 8935573..1979f6f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java @@ -41,13 +41,13 @@ public ResponseEntity createAndStoreServiceList() { /** * 공공서비스 상세정보 전체 동기화 (페이징 처리) */ - @PostMapping("/serviceDetailList") + @PostMapping("/serviceDetail") public ResponseEntity createAndStoreServiceDetailList() { validator.validateAndHandleException(() -> { // 전체 상세정보 동기화 (페이징 처리) - publicServiceDataService.syncPublicServiceDetailData(SERVICE_DETAIL_LIST); + publicServiceDataService.syncPublicServiceDetailData(SERVICE_DETAIL); return null; - }, SERVICE_DETAIL_LIST); + }, SERVICE_DETAIL); return ResponseEntity.status(HttpStatus.OK).body("공공서비스 상세정보 데이터 동기화 완료"); } @@ -55,13 +55,13 @@ public ResponseEntity createAndStoreServiceDetailList() { /** * 공공서비스 지원조건 전체 동기화 (페이징 처리) */ - @PostMapping("/supportConditionsList") + @PostMapping("/supportConditions") public ResponseEntity createAndStoreSupportConditionsList() { validator.validateAndHandleException(() -> { // 전체 지원조건 동기화 (페이징 처리) - publicServiceDataService.syncPublicServiceConditionsData(SERVICE_CONDITIONS_LIST); + publicServiceDataService.syncPublicServiceConditionsData(SERVICE_CONDITIONS); return null; - }, SERVICE_CONDITIONS_LIST); + }, SERVICE_CONDITIONS); return ResponseEntity.status(HttpStatus.OK).body("공공서비스 지원조건 데이터 동기화 완료"); } @@ -85,4 +85,18 @@ public ResponseEntity> getServiceListByPage( return ResponseEntity.status(HttpStatus.OK).body(result); } + + // 통합 동기화 + @PostMapping("/sync-all") + public ResponseEntity syncAllPublicServiceData() { + // 순차적으로 실행 + createAndStoreServiceList(); + createAndStoreServiceDetailList(); + createAndStoreSupportConditionsList(); + + // 미사용 데이터 정리 + publicServiceDataService.cleanupObsoleteServices(); + + return ResponseEntity.status(HttpStatus.OK).body("모든 공공서비스 데이터 동기화 완료"); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java index 9e7a019..505fa88 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java @@ -3,10 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.List; @@ -23,13 +20,14 @@ public class PublicServiceConditionsDataDto { private long perPage; @Getter + @Setter @NoArgsConstructor @AllArgsConstructor @Builder @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("JA0101") private String targetGenderMale; @@ -54,46 +52,79 @@ public static class Data { private String incomeLevelVeryHigh; // 중위소득 200% 초과 // Special Group + @JsonProperty("JA0401") private String JA0401; // 다문화가족 + @JsonProperty("JA0402") private String JA0402; // 북한이탈주민 + @JsonProperty("JA0403") private String JA0403; // 한부모가정/조손가정 + @JsonProperty("JA0404") private String JA0404; // 1인가구 + @JsonProperty("JA0328") private String JA0328; // 장애인 + @JsonProperty("JA0329") private String JA0329; // 국가보훈대상자 + @JsonProperty("JA0330") private String JA0330; // 질병/질환자 // Family Type - // private String JA0410; // 해당사항 없음 + @JsonProperty("JA0411") private String JA0411; // 다자녀가구 + @JsonProperty("JA0412") private String JA0412; // 무주택세대 + @JsonProperty("JA0413") private String JA0413; // 신규전입 + @JsonProperty("JA0414") private String JA0414; // 확대가족 // Occupation + @JsonProperty("JA0313") private String JA0313; // 농업인 + @JsonProperty("JA0314") private String JA0314; // 어업인 + @JsonProperty("JA0315") private String JA0315; // 축산업인 + @JsonProperty("JA0316") private String JA0316; // 임업인 + @JsonProperty("JA0317") private String JA0317; // 초등학생 + @JsonProperty("JA0318") private String JA0318; // 중학생 + @JsonProperty("JA0319") private String JA0319; // 고등학생 + @JsonProperty("JA0320") private String JA0320; // 대학생/대학원생 + @JsonProperty("JA0326") private String JA0326; // 근로자/직장인 + @JsonProperty("JA0327") private String JA0327; // 구직자/실업자 // Business Type + @JsonProperty("JA1101") private String JA1101; // 예비 창업자 + @JsonProperty("JA1102") private String JA1102; // 영업중 + @JsonProperty("JA1103") private String JA1103; // 생계곤란/폐업예정자 + @JsonProperty("JA1201") private String JA1201; // 음식업 + @JsonProperty("JA1202") private String JA1202; // 제조업 + @JsonProperty("JA1299") private String JA1299; // 기타업종 + @JsonProperty("JA2101") private String JA2101; // 중소기업 + @JsonProperty("JA2102") private String JA2102; // 사회복지시설 + @JsonProperty("JA2103") private String JA2103; // 기관/단체 + @JsonProperty("JA2201") private String JA2201; // 제조업 + @JsonProperty("JA2202") private String JA2202; // 농업, 임업 및 어업 + @JsonProperty("JA2203") private String JA2203; // 정보통신업 + @JsonProperty("JA2299") private String JA2299; // 기타업종 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java index 25643ff..3eac2e3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java @@ -29,7 +29,7 @@ public class PublicServiceDataDto { @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("서비스명") private String serviceName; diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java index 742ea6c..147f3ca 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java @@ -3,10 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.List; @@ -30,7 +27,7 @@ public class PublicServiceDetailDataDto { @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("서비스명") private String serviceName; diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java index 8c8a333..08ac767 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java @@ -30,6 +30,8 @@ public interface PublicServiceDataMapper { PublicService updateFromDetailData(@MappingTarget PublicService publicService, PublicServiceDetailDataDto.Data data); // 공공서비스 지원조건 데이터 매핑 + @Mapping(target = "targetGenderMale", source = "targetGenderMale") + @Mapping(target = "targetGenderFemale", source = "targetGenderFemale") @Mapping(target = "incomeLevel", expression = "java(mapIncomeLevel(data))") PublicService updateFromConditionsData(@MappingTarget PublicService publicService, PublicServiceConditionsDataDto.Data data); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java new file mode 100644 index 0000000..6681011 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java @@ -0,0 +1,37 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.document; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "service_info") +public class PublicData { + @Id + private String id; + + private String publicServiceId; + private String serviceName; + private String summaryPurpose; + private String serviceCategory; + private List specialGroup; + private List familyType; + + private List occupations; + private List businessTypes; + + // Support conditions fields + private String targetGenderMale; + private String targetGenderFemale; + private Integer targetAgeStart; + private Integer targetAgeEnd; + private String incomeLevel; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java new file mode 100644 index 0000000..afea3bf --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java @@ -0,0 +1,20 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.repository; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PublicDataMongoRepository extends MongoRepository { + Optional findByPublicServiceId(String publicServiceId); + List findAllByPublicServiceId(String publicServiceId); + List findAllByPublicServiceIdIn(Collection publicServiceIds); + + @Query(value = "{}", fields = "{ 'publicServiceId' : 1 }") + List findAllPublicServiceIds(); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java new file mode 100644 index 0000000..8cd794a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -0,0 +1,236 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.repository.PublicDataMongoRepository; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.bson.Document; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicDataMongoService { + + private final PublicDataMongoRepository mongoRepository; + private final MongoTemplate mongoTemplate; + + /** + * 단일 공공서비스 엔티티를 MongoDB에 저장 + */ + public PublicData saveToMongo(PublicService publicService) { + PublicData document = convertToDocument(publicService); + return mongoRepository.save(document); + } + + /** + * 공공서비스 엔티티를 MongoDB 문서로 변환 + */ + private PublicData convertToDocument(PublicService publicService) { + // 특수 그룹 정보 추출 + List specialGroups = publicService.getSpecialGroups().stream() + .map(sg -> sg.getSpecialGroupEnum().getType()) + .collect(Collectors.toList()); + + // 가족 유형 정보 추출 + List familyTypes = publicService.getFamilyTypes().stream() + .map(ft -> ft.getFamilyTypeEnum().getType()) + .collect(Collectors.toList()); + + // 직업 정보 추출 + List occupations = publicService.getOccupations().stream() + .map(occ -> occ.getOccupationEnum().getType()) + .collect(Collectors.toList()); + + // 사업체 유형 정보 추출 + List businessTypes = publicService.getBusinessTypes().stream() + .map(bt -> bt.getBusinessTypeEnum().getType()) + .collect(Collectors.toList()); + + // MongoDB 문서 생성 및 반환 + return PublicData.builder() + .publicServiceId(publicService.getId()) + .serviceName(publicService.getServiceName()) + .summaryPurpose(publicService.getSummaryPurpose()) + .serviceCategory(publicService.getServiceCategory().getType()) + .specialGroup(specialGroups) + .familyType(familyTypes) + .occupations(occupations) + .businessTypes(businessTypes) + .targetGenderMale(publicService.getTargetGenderMale()) + .targetGenderFemale(publicService.getTargetGenderFemale()) + .targetAgeStart(publicService.getTargetAgeStart()) + .targetAgeEnd(publicService.getTargetAgeEnd()) + .incomeLevel(publicService.getIncomeLevel()) + .build(); + } + + /** + * 서비스 ID로 문서 조회 + */ + public Optional findByPublicServiceId(String publicServiceId) { + return mongoRepository.findByPublicServiceId(publicServiceId); + } + + /** + * 기존 문서 업데이트 또는 새 문서 생성 + */ + public PublicData updateOrCreateDocument(PublicService publicService) { + Optional existingDoc = mongoRepository.findByPublicServiceId(publicService.getId()); + + if (existingDoc.isPresent()) { + // 기존 문서의 ID 유지하면서 데이터 업데이트 + PublicData newData = convertToDocument(publicService); + newData.setId(existingDoc.get().getId()); + return mongoRepository.save(newData); + } else { + // 새 문서 생성 + return saveToMongo(publicService); + } + } + + /** + * 여러 서비스 문서 업데이트 또는 생성 + */ + public void updateOrCreateBulkDocuments(List services) { + // 모든 service ID 목록 + List serviceIds = services.stream() + .map(PublicService::getId) + .collect(Collectors.toList()); + + // publicServiceId로 기존 문서 조회 (중요: findAllByPublicServiceId를 사용) + Map existingDocsMap = mongoRepository.findAllByPublicServiceIdIn(serviceIds).stream() + .collect(Collectors.toMap( + PublicData::getPublicServiceId, + doc -> doc, + (a, b) -> a // 중복 시 첫 번째 문서 유지 + )); + + // 처리할 문서 준비 + List docsToSave = services.stream() + .map(service -> { + PublicData doc = convertToDocument(service); + if (existingDocsMap.containsKey(service.getId())) { + // 기존 문서의 ID 유지 + doc.setId(existingDocsMap.get(service.getId()).getId()); + } + return doc; + }) + .collect(Collectors.toList()); + + // 저장 + mongoRepository.saveAll(docsToSave); + } + + // 첫 실행 시에만 중복 제거 및 인덱스 생성 + @PostConstruct + public void ensureIndexes() { + try { + // 1. 기존 인덱스 확인 + boolean hasUniqueIndex = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName())) { + hasUniqueIndex = indexInfo.isUnique(); + break; + } + } + + // 2. 유니크 인덱스가 없는 경우만 처리 + if (!hasUniqueIndex) { + // 2.1 일반 인덱스 존재 여부 확인 + boolean hasNonUniqueIndex = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName()) && !indexInfo.isUnique()) { + hasNonUniqueIndex = true; + break; + } + } + + // 2.2 일반 인덱스가 있다면 삭제 + if (hasNonUniqueIndex) { + mongoTemplate.indexOps("service_info").dropIndex("publicServiceId_1"); + log.info("기존 비유니크 인덱스 삭제: publicServiceId_1"); + } + + // 2.3 최적화된 중복 제거 실행 + deduplicateMongoDocuments(); + + // 2.4 유니크 인덱스 생성 + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } else { + log.info("MongoDB 유니크 인덱스 이미 존재함: publicServiceId_1"); + } + } catch (Exception e) { + log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); + // 인덱스 생성 실패해도 애플리케이션은 시작되도록 함 + } + } + + @Transactional + public void deduplicateMongoDocuments() { + log.info("MongoDB 문서 중복 제거 시작 (최적화 버전)"); + + // 모든 publicServiceId와 해당 문서 ID를 그룹화하여 한 번에 조회 + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation( + Aggregation.group("publicServiceId") + .first("_id").as("firstId") + .push("_id").as("allIds") + .count().as("count") + ), + "service_info", + Document.class + ); + + int totalProcessed = 0; + int totalRemoved = 0; + + for (Document doc : results.getMappedResults()) { + int count = doc.getInteger("count"); + + // 중복이 있는 경우에만 처리 + if (count > 1) { + String publicServiceId = doc.getString("_id"); + List allIds = (List) doc.get("allIds"); + Object firstId = doc.get("firstId"); + + // 첫 번째 문서를 제외한 나머지 문서 삭제 + for (int i = 0; i < allIds.size(); i++) { + Object currentId = allIds.get(i); + if (!currentId.equals(firstId)) { + mongoTemplate.remove(Query.query(Criteria.where("_id").is(currentId)), "service_info"); + totalRemoved++; + } + } + } + + totalProcessed++; + if (totalProcessed % 1000 == 0) { + log.info("중복 제거 진행 중: {}/{} 그룹 처리, {}개 제거됨", + totalProcessed, results.getMappedResults().size(), totalRemoved); + } + } + + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 그룹 중 {}개 중복 제거됨", + totalProcessed, totalRemoved); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java index 1499136..e6e7990 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java @@ -32,11 +32,11 @@ public void syncAllPublicServiceData() { // 2. 공공서비스 상세정보 동기화 log.info("2. 공공서비스 상세정보 동기화 시작"); - publicServiceDataService.syncPublicServiceDetailData(PublicDataPath.SERVICE_DETAIL_LIST); + publicServiceDataService.syncPublicServiceDetailData(PublicDataPath.SERVICE_DETAIL); // 3. 공공서비스 지원조건 동기화 log.info("3. 공공서비스 지원조건 동기화 시작"); - publicServiceDataService.syncPublicServiceConditionsData(PublicDataPath.SERVICE_CONDITIONS_LIST); + publicServiceDataService.syncPublicServiceConditionsData(PublicDataPath.SERVICE_CONDITIONS); // 4. 미사용 데이터 정리 log.info("미사용 공공서비스 데이터 정리 시작"); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java index 6d31222..e67841f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java @@ -33,24 +33,31 @@ void updateSpecialGroups(PublicService publicService, PublicServiceConditionsDat List specialGroups = new ArrayList<>(); if ("Y".equals(data.getJA0401())) { + log.debug("다문화가족 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_MULTI_CULTURAL)); } if ("Y".equals(data.getJA0402())) { + log.debug("북한이탈주민 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_NORTH_KOREAN_DEFECTOR)); } if ("Y".equals(data.getJA0403())) { + log.debug("한부모가정/조손가정 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_SINGLE_PARENT_FAMILY)); } if ("Y".equals(data.getJA0404())) { + log.debug("1인가구 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_SINGLE_MEMBER_HOUSEHOLD)); } if ("Y".equals(data.getJA0328())) { + log.debug("장애인 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_DISABLED)); } if ("Y".equals(data.getJA0329())) { + log.debug("국가보훈대상자 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_NATIONAL_MERIT_RECIPIENT)); } if ("Y".equals(data.getJA0330())) { + log.debug("질병/질환자 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_CHRONIC_ILLNESS)); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java index f6d8315..fd68168 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java @@ -4,15 +4,18 @@ import com.hyetaekon.hyetaekon.common.publicdata.dto.PublicServiceDataDto; import com.hyetaekon.hyetaekon.common.publicdata.dto.PublicServiceDetailDataDto; import com.hyetaekon.hyetaekon.common.publicdata.mapper.PublicServiceDataMapper; +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicDataPath; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicServiceDataValidate; import com.hyetaekon.hyetaekon.publicservice.entity.*; import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.web.client.RestTemplate; @@ -28,7 +31,7 @@ @Service @RequiredArgsConstructor public class PublicServiceDataServiceImpl implements PublicServiceDataService { - + private final PublicDataMongoService publicDataMongoService; private final PublicServiceRepository publicServiceRepository; private final PublicServiceDataMapper publicServiceDataMapper; private final PublicServiceDataProviderService publicServiceDataProviderService; @@ -36,7 +39,7 @@ public class PublicServiceDataServiceImpl implements PublicServiceDataService { private final PublicServiceDataValidate validator; private static final int DEFAULT_PAGE_SIZE = 1000; // 기본 페이지 크기를 1000으로 설정 - private final Set currentServiceIds = ConcurrentHashMap.newKeySet(); // 현재 동기화된 서비스 ID 저장 + private final Set currentServiceIds = ConcurrentHashMap.newKeySet(); // 현재 동기화된 서비스 ID 저장 /** * 공공서비스 목록 데이터 호출 (페이징 처리) @@ -145,7 +148,7 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { /** * 공통 데이터 동기화 메서드 - 중복 코드 제거를 위한 템플릿 메서드 */ - private long syncDataWithPaging( + private void syncDataWithPaging( PublicDataPath apiPath, BiFunction> fetcher, // 데이터 조회 함수 Function> dataExtractor, // DTO에서 데이터 추출 함수 @@ -204,7 +207,6 @@ private long syncDataWithPaging( } log.info("{} 전체 동기화 완료: 총 {}건", operationName, totalProcessed); - return totalProcessed; } /** @@ -237,14 +239,16 @@ public List upsertServiceData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 목록 데이터 {}건 저장 완료", validatedData.size()); @@ -260,12 +264,12 @@ public List upsertServiceDetailData(List entitiesToSave = new ArrayList<>(); // 서비스 ID 목록 생성 - Set serviceIds = dataList.stream() + Set serviceIds = dataList.stream() .map(PublicServiceDetailDataDto.Data::getServiceId) .collect(Collectors.toSet()); // 서비스 ID로 한 번에 조회 (N+1 문제 방지) - Map serviceMap = publicServiceRepository.findAllById(serviceIds) + Map serviceMap = publicServiceRepository.findAllById(serviceIds) .stream() .collect(Collectors.toMap(PublicService::getId, service -> service)); @@ -289,14 +293,16 @@ public List upsertServiceDetailData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 상세정보 데이터 {}건 저장 완료", validatedData.size()); @@ -306,18 +312,18 @@ public List upsertServiceDetailData(List upsertSupportConditionsData(List dataList) { List validatedData = new ArrayList<>(); List entitiesToSave = new ArrayList<>(); // 서비스 ID 목록 생성 - Set serviceIds = dataList.stream() + Set serviceIds = dataList.stream() .map(PublicServiceConditionsDataDto.Data::getServiceId) .collect(Collectors.toSet()); // 서비스 ID로 한 번에 조회 (N+1 문제 방지) - Map serviceMap = publicServiceRepository.findAllById(serviceIds) + Map serviceMap = publicServiceRepository.findAllById(serviceIds) .stream() .collect(Collectors.toMap(PublicService::getId, service -> service)); @@ -345,14 +351,16 @@ public List upsertSupportConditionsData(Lis // 배치 처리 최적화: 1000개 단위로 저장 if (entitiesToSave.size() >= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 지원조건 데이터 {}건 저장 완료", validatedData.size()); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java index 2cfd539..633c97a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java @@ -7,8 +7,8 @@ @Getter public enum PublicDataPath { SERVICE_LIST("/serviceList"), - SERVICE_DETAIL_LIST("/serviceDetailList"), - SERVICE_CONDITIONS_LIST("/supportConditionsList"); + SERVICE_DETAIL("/serviceDetail"), + SERVICE_CONDITIONS("/supportConditions"); private final String path; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java index a1fda6b..c177ea5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java @@ -60,14 +60,19 @@ public boolean validatePublicServiceData(PublicServiceDataDto.Data data) { return true; } + // PublicServiceDataValidate.java 수정 public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data data) { - if (data.getServicePurpose() == null || data.getServicePurpose().isEmpty() || - data.getSupportTarget() == null || data.getSupportTarget().isEmpty() || - data.getSupportDetail() == null || data.getSupportDetail().isEmpty() || - data.getSupportType() == null || data.getSupportType().isEmpty() || - data.getApplicationMethod() == null || data.getApplicationMethod().isEmpty() || - data.getApplicationDeadline() == null || data.getApplicationDeadline().isEmpty() || - data.getGoverningAgency() == null || data.getGoverningAgency().isEmpty()) { + // 필수 필드 리스트를 먼저 확인 + boolean isValid = data.getServicePurpose() != null && !data.getServicePurpose().isEmpty() && + data.getSupportTarget() != null && !data.getSupportTarget().isEmpty() && + data.getSupportDetail() != null && !data.getSupportDetail().isEmpty() && + data.getSupportType() != null && !data.getSupportType().isEmpty() && + data.getApplicationMethod() != null && !data.getApplicationMethod().isEmpty() && + data.getApplicationDeadline() != null && !data.getApplicationDeadline().isEmpty() && + data.getGoverningAgency() != null && !data.getGoverningAgency().isEmpty() && + data.getContactInfo() != null && !data.getContactInfo().isEmpty(); + + if (!isValid) { log.warn("⚠️ 공공 서비스 상세내용 ID {}에 필수 데이터가 누락되었습니다.", data.getServiceId()); return false; } @@ -75,28 +80,28 @@ public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data d } public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDto.Data data) { - // 성별 조건 확인 (필수) + // 성별 조건 확인 boolean hasGenderCondition = "Y".equals(data.getTargetGenderMale()) || "Y".equals(data.getTargetGenderFemale()); - // 성별 정보가 없으면 유효하지 않은 데이터로 간주 + // 성별 조건이 없는 경우, 기본값으로 모두 Y 설정 if (!hasGenderCondition) { - log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 지원 조건이 없습니다.", data.getServiceId()); - return false; + log.info("ℹ️ 공공 서비스 지원조건 ID {}에 성별 지원 조건이 없습니다. 기본값으로 남성/여성 모두 Y로 설정합니다.", data.getServiceId()); + data.setTargetGenderMale("Y"); + data.setTargetGenderFemale("Y"); + // hasGenderCondition = true; } - // 특수 그룹 조건 확인 + // 다른 조건들 검사 (경고만 로깅하고 실제로는 모든 데이터 허용) boolean hasSpecialGroupCondition = "Y".equals(data.getJA0401()) || "Y".equals(data.getJA0402()) || "Y".equals(data.getJA0403()) || "Y".equals(data.getJA0404()) || "Y".equals(data.getJA0328()) || "Y".equals(data.getJA0329()) || "Y".equals(data.getJA0330()); - // 가족 유형 조건 확인 boolean hasFamilyTypeCondition = "Y".equals(data.getJA0411()) || "Y".equals(data.getJA0412()) || "Y".equals(data.getJA0413()) || "Y".equals(data.getJA0414()); - // 직업 유형 조건 확인 boolean hasOccupationCondition = "Y".equals(data.getJA0313()) || "Y".equals(data.getJA0314()) || "Y".equals(data.getJA0315()) || "Y".equals(data.getJA0316()) || @@ -104,7 +109,6 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt "Y".equals(data.getJA0319()) || "Y".equals(data.getJA0320()) || "Y".equals(data.getJA0326()) || "Y".equals(data.getJA0327()); - // 사업체 유형 조건 확인 boolean hasBusinessTypeCondition = "Y".equals(data.getJA1101()) || "Y".equals(data.getJA1102()) || "Y".equals(data.getJA1103()) || "Y".equals(data.getJA1201()) || @@ -114,13 +118,13 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt "Y".equals(data.getJA2202()) || "Y".equals(data.getJA2203()) || "Y".equals(data.getJA2299()); - // 성별 외에 다른 지원 조건이 하나라도 있어야 함 if (!(hasSpecialGroupCondition || hasFamilyTypeCondition || hasOccupationCondition || hasBusinessTypeCondition)) { log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 외 다른 지원 조건이 없습니다.", data.getServiceId()); - return false; + // 경고만 로깅하고 데이터는 허용 } + // 항상 true 반환하여 모든 데이터 허용 return true; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java index bc69467..74dd1aa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -1,13 +1,20 @@ package com.hyetaekon.hyetaekon.post.controller; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.post.dto.*; +import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.service.PostService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; +@Slf4j @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -15,40 +22,55 @@ public class PostController { private final PostService postService; - // ✅ 게시글 생성 - @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); - } + // PostType에 해당하는 게시글 목록 조회 + @GetMapping("/type") + public ResponseEntity> getPosts( + @RequestParam(required = false, defaultValue = "전체") String postType, + @RequestParam(required = false) String keyword, // 🔥 제목 검색 추가 + @RequestParam(defaultValue = "createdAt") String sortBy, // 🔥 정렬 키워드 추가 + @RequestParam(defaultValue = "DESC") String direction, // 🔥 정렬 방향 추가 + @PageableDefault(page = 0, size = 10) Pageable pageable) { - // ✅ 특정 게시글 조회 - @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { - return ResponseEntity.ok(postService.getPostById(postId)); + PostType type = PostType.fromKoreanName(postType); + + if (type == PostType.ALL) { + return ResponseEntity.ok(postService.getAllPosts(keyword, sortBy, direction, pageable)); + } else { + return ResponseEntity.ok(postService.getPostsByType(type, keyword, sortBy, direction, pageable)); + } } - // ✅ 특정 카테고리의 게시글 조회 - @GetMapping("/category/{categoryId}") - public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { - return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); + // User, Admin에 따라 다른 접근 가능 + // ✅ 특정 게시글 상세 조회 + @GetMapping("/{postId}") + public ResponseEntity getPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); } - // ✅ 모든 게시글 조회 - @GetMapping - public ResponseEntity> getAllPosts() { - return ResponseEntity.ok(postService.getAllPosts()); + // ✅ 게시글 생성 + @PostMapping + public ResponseEntity createPost( + @ModelAttribute PostCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.createPost(requestDto, userDetails.getId())); } - // ✅ 게시글 수정 + // ✅ 게시글 수정 - 본인 @PutMapping("/{postId}") - public ResponseEntity updatePost(@PathVariable Long postId, @RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.updatePost(postId, postDto)); + public ResponseEntity updatePost( + @PathVariable Long postId, + @ModelAttribute PostUpdateRequestDto updateDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.updatePost(postId, updateDto, userDetails.getId())); } - // ✅ 게시글 삭제 (soft delete 방식 사용 가능) + // ✅ 게시글 삭제 (soft delete 방식 사용 가능) - 본인 혹은 관리자 @DeleteMapping("/{postId}") - public ResponseEntity deletePost(@PathVariable Long postId) { - postService.deletePost(postId); + public ResponseEntity deletePost( + @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.deletePost(postId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java new file mode 100644 index 0000000..8d5cf99 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MyPostListResponseDto { + private Long postId; + private String title; + private String content; + private String nickName; // 작성자 닉네임 + private LocalDateTime createdAt; + private int recommendCnt; + private int commentCnt; + private int viewCnt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java new file mode 100644 index 0000000..c574aac --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostCreateRequestDto { + private String nickName; + private String title; + private String content; + private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List images; // 이미지 파일 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java similarity index 58% rename from src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java rename to src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index 61cea56..2f75f66 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -1,27 +1,28 @@ package com.hyetaekon.hyetaekon.post.dto; -import lombok.Getter; -import lombok.Setter; + +import lombok.*; + import java.time.LocalDateTime; import java.util.List; @Getter @Setter -public class PostDto { - private Long id; - private Long userId; - private Long publicServiceId; +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDetailResponseDto { + private Long postId; + private String nickName; // 작성자 닉네임 private String title; private String content; private LocalDateTime createdAt; - private LocalDateTime deletedAt; // 삭제일 추가 private String postType; - private String serviceUrl; private int recommendCnt; - private int viewCount; + private int viewCnt; private String urlTitle; private String urlPath; private String tags; - private Long categoryId; // 추가됨 private List imageUrls; // ✅ 이미지 URL 리스트 추가 + private boolean recommended; // 현재 로그인한 사용자의 추천 여부 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java new file mode 100644 index 0000000..037592c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -0,0 +1,19 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; + + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostListResponseDto { + private Long postId; + private String title; + private String nickName; // 작성자 닉네임 + private LocalDateTime createdAt; + private int viewCnt; + private String postType; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java new file mode 100644 index 0000000..600b6a2 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostUpdateRequestDto { + private String nickName; + private String title; + private String content; + // private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List images; // 이미지 파일 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java index b2c2df1..c64df29 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -1,17 +1,23 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; + import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @Getter @Setter -@Table(name = "posts") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "post") public class Post { @Id @@ -26,27 +32,37 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + @Column(columnDefinition = "VARCHAR(20) CHARACTER SET utf8mb4", nullable = false) // ✅ 제목 20자 제한 private String title; - @Column(length = 500, nullable = false) // ✅ 내용 500자 제한 + @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 private String content; - private LocalDateTime createdAt = LocalDateTime.now(); + @Builder.Default + private LocalDateTime createdAt = null; // 빌더 사용 시 null로 초기화 private LocalDateTime deletedAt; - private int recommendCnt; // 추천수 + @Builder.Default + @Column(name = "recommend_cnt") + private int recommendCnt = 0; // 추천수 + + @Builder.Default + @Column(name = "view_count") + private int viewCnt = 0; // 조회수 - private int viewCount; // 조회수 + // TODO: 댓글 생성/수정 시 업데이트 + @Builder.Default + @Column(name = "comment_cnt") + private int commentCnt = 0; // 댓글수 - @Column(name = "post_type") + @Column(name = "post_type", nullable = false) @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) private PostType postType; private String serviceUrl; - @Column(length = 12) // ✅ 관련 링크 제목 12자 제한 + @Column(columnDefinition = "VARCHAR(12) CHARACTER SET utf8mb4") // ✅ url제목 12자 제한 private String urlTitle; private String urlPath; @@ -57,6 +73,44 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @Builder.Default + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private List postImages; // ✅ 게시글 이미지와 연결 + @Builder.Default + private List recommends = new ArrayList<>(); + + // 저장 시점에 현재 시간으로 설정 + @PrePersist + public void prePersist() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } + + // 조회수 증가 + public void incrementViewCnt() { + this.viewCnt++; + } + + // 추천수 증가 + public void incrementRecommendCnt() { + this.recommendCnt++; + } + + // 추천수 감소 + public void decrementRecommendCnt() { + this.recommendCnt = Math.max(0, this.recommendCnt - 1); + } + + public void incrementCommentCnt() { + this.commentCnt++; + } + + public void decrementCommentCnt() { + this.commentCnt = Math.max(0, this.commentCnt - 1); + } + + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java index 177ac34..7325e94 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -1,12 +1,16 @@ package com.hyetaekon.hyetaekon.post.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; + +import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class PostImage { @Id @@ -19,4 +23,16 @@ public class PostImage { @Column(name = "image_url", length = 255) private String imageUrl; + + private LocalDateTime deletedAt; + + // 이미지가 삭제되었는지 확인하는 메소드 + public boolean isDeleted() { + return deletedAt != null; + } + + // Soft delete 처리 메소드 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java index db9cb66..c704720 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java @@ -1,7 +1,30 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum PostType { - QUESTION, // 질문 게시판 - FREE, // 자유 게시판 - GREETING // 인사 게시판 + ALL("전체"), + QUESTION("질문"), + FREE("자유"), + GREETING("인사"); + + @JsonValue + private final String koreanName; + + /** + * 한글 이름으로 PostType을 찾습니다. + */ + public static PostType fromKoreanName(String koreanName) { + for (PostType type : values()) { + if (type.getKoreanName().equals(koreanName)) { + return type; + } + } + // 일치하는 이름이 없거나 null인 경우 기본값으로 ALL 반환 + return ALL; + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java new file mode 100644 index 0000000..e2c366c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java @@ -0,0 +1,38 @@ +package com.hyetaekon.hyetaekon.post.mapper; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface PostImageMapper { + + // 게시글 이미지 변환 + default PostImage toPostImage(String url, Post post) { + if (url == null || post == null) { + throw new IllegalArgumentException("url 또는 post가 null일 수 없습니다."); + } + + return PostImage.builder() + .imageUrl(url) + .post(post) + .build(); + } + + // URL 리스트로 PostImage 리스트 생성 + default List toEntityList(List uploadedUrls, Post post) { + if (uploadedUrls == null || uploadedUrls.isEmpty()) { + return Collections.emptyList(); + } + return uploadedUrls.stream() + .map(url -> toPostImage(url, post)) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java index 908c493..e993c25 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -1,62 +1,55 @@ package com.hyetaekon.hyetaekon.post.mapper; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.entity.PostImage; -import com.hyetaekon.hyetaekon.post.entity.PostType; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; -import com.hyetaekon.hyetaekon.user.entity.User; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; +import org.mapstruct.*; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PostMapper { - @Mapping(target = "imageUrls", expression = "java(mapPostImages(post))") - @Mapping(target = "postType", expression = "java(post.getPostType() != null ? post.getPostType().name() : null)") - PostDto toDto(Post post); - - default Post toEntity(PostDto dto) { - Post post = new Post(); - // 👇 User 객체 세팅 - User user = new User(); - user.setId(dto.getUserId()); - post.setUser(user); - - // 👇 PublicService 객체 세팅 - PublicService publicService = new PublicService(); - publicService.setId(dto.getPublicServiceId()); - post.setPublicService(publicService); - post.setTitle(dto.getTitle()); - post.setContent(dto.getContent()); - post.setPostType(dto.getPostType() != null ? PostType.valueOf(dto.getPostType()) : null); - post.setServiceUrl(dto.getServiceUrl()); - post.setUrlTitle(dto.getUrlTitle()); - post.setUrlPath(dto.getUrlPath()); - post.setTags(dto.getTags()); - post.setCategoryId(dto.getCategoryId()); - - if (dto.getImageUrls() != null) { - List images = dto.getImageUrls().stream() - .map(url -> { - PostImage img = new PostImage(); - img.setImageUrl(url); - img.setPost(post); - return img; - }).collect(Collectors.toList()); - post.setPostImages(images); + // Post -> PostListResponseDto 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + PostListResponseDto toPostListDto(Post post); + + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + MyPostListResponseDto toMyPostListDto(Post post); + + // Post -> PostDetailResponseDto 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + @Mapping(target = "imageUrls", source = "postImages", qualifiedByName = "mapPostImagesToUrls") + PostDetailResponseDto toPostDetailDto(Post post); + + // PostCreateRequestDto -> Post 변환 (새 게시글 생성) + @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") + @Mapping(target = "postType", ignore = true) + Post toEntity(PostCreateRequestDto createDto); + + // null 아닌 값만 업데이트 + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "postType", ignore = true) + void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); + + // 이미지만 URL 리스트로 변환 (soft delete 처리된 것 제외) + @Named("mapPostImagesToUrls") + default List mapPostImagesToUrls(List postImages) { + if (postImages == null) { + return Collections.emptyList(); } - - return post; - } - - default List mapPostImages(Post post) { - if (post.getPostImages() == null) return null; - return post.getPostImages().stream() - .map(PostImage::getImageUrl) - .collect(Collectors.toList()); + return postImages.stream() + .filter(img -> img.getDeletedAt() == null) + .map(PostImage::getImageUrl) + .filter(Objects::nonNull) + .collect(Collectors.toList()); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java new file mode 100644 index 0000000..7994337 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.post.repository; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostImageRepository extends JpaRepository { + List findByPostAndDeletedAtIsNull(Post post); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 2734c4e..502b593 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -1,14 +1,38 @@ package com.hyetaekon.hyetaekon.post.repository; import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface PostRepository extends JpaRepository { - List findByCategoryId(Long categoryId); // 추가됨 + // 삭제되지 않은 모든 게시글 조회 (페이징) + Page findByDeletedAtIsNull(Pageable pageable); + + // 특정 타입의 삭제되지 않은 게시글 조회 (페이징) + Page findByPostTypeAndDeletedAtIsNull(PostType postType, Pageable pageable); + + // ID로 삭제되지 않은 게시글 조회 + Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByUser_IdAndDeletedAtIsNull(Long userId); + + // 특정 사용자가 작성한 게시글 조회 + Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + + // 특정 사용자가 추천한 게시글 조회 + Page findByRecommendsUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + + // 제목 검색 + 삭제되지 않은 게시글 + Page findByTitleContainingAndDeletedAtIsNull(String keyword, Pageable pageable); + + // 제목 검색 + 특정 타입 + 삭제되지 않은 게시글 + Page findByPostTypeAndTitleContainingAndDeletedAtIsNull(PostType postType, String keyword, Pageable pageable); + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index ae576b8..7764d71 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,76 +1,275 @@ package com.hyetaekon.hyetaekon.post.service; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.s3bucket.service.S3BucketService; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; import com.hyetaekon.hyetaekon.post.entity.PostType; +import com.hyetaekon.hyetaekon.post.mapper.PostImageMapper; import com.hyetaekon.hyetaekon.post.mapper.PostMapper; +import com.hyetaekon.hyetaekon.post.repository.PostImageRepository; import com.hyetaekon.hyetaekon.post.repository.PostRepository; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; +import java.util.Set; + +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; +@Slf4j @Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; + private final PostImageRepository postImageRepository; + private final UserRepository userRepository; + private final RecommendRepository recommendRepository; private final PostMapper postMapper; + private final PostImageMapper postImageMapper; + private final S3BucketService s3BucketService; + + // 이미지 업로드 제한 설정 + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_FILES_COUNT = 5; // 최대 5개 이미지 + private static final Set ALLOWED_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/gif" + ); - public List getAllPosts() { - return postRepository.findAll() - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 전체 게시글 목록 조회 (제목 검색 + 정렬) + */ + public Page getAllPosts(String keyword, String sortBy, String direction, Pageable pageable) { + Pageable sortedPageable = createSortedPageable(pageable, sortBy, direction); + + if (keyword != null && !keyword.trim().isEmpty()) { + return postRepository.findByTitleContainingAndDeletedAtIsNull(keyword, sortedPageable) + .map(postMapper::toPostListDto); + } else { + return postRepository.findByDeletedAtIsNull(sortedPageable) + .map(postMapper::toPostListDto); + } } - public PostDto getPostById(Long id) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - return postMapper.toDto(post); + /** + * 특정 타입 게시글 목록 조회 (제목 검색 + 정렬) + */ + public Page getPostsByType(PostType postType, String keyword, String sortBy, String direction, Pageable pageable) { + Pageable sortedPageable = createSortedPageable(pageable, sortBy, direction); + + if (keyword != null && !keyword.trim().isEmpty()) { + return postRepository.findByPostTypeAndTitleContainingAndDeletedAtIsNull(postType, keyword, sortedPageable) + .map(postMapper::toPostListDto); + } else { + return postRepository.findByPostTypeAndDeletedAtIsNull(postType, sortedPageable) + .map(postMapper::toPostListDto); + } } - public List getPostsByCategoryId(Long categoryId) { // 추가됨 - return postRepository.findByCategoryId(categoryId) - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 정렬 기준을 적용한 Pageable 생성 + */ + private Pageable createSortedPageable(Pageable pageable, String sortBy, String direction) { + Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); } - public PostDto createPost(PostDto postDto) { - Post post = postMapper.toEntity(postDto); - Post savedPost = postRepository.save(post); - return postMapper.toDto(savedPost); + /** + * 특정 게시글 상세 조회(로그인 시) + */ + @Transactional + public PostDetailResponseDto getPostById(Long postId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 조회수 증가 + post.incrementViewCnt(); + + // 사용자의 추천 여부 확인 + boolean recommended = recommendRepository.existsByUserIdAndPostId(userId, postId); + + // DTO 변환 및 추천 여부 설정 + PostDetailResponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); + + return responseDto; } - public PostDto updatePost(Long id, PostDto postDto) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - User user = new User(); - user.setId(postDto.getUserId()); + /** + * 게시글 생성(로그인 시) + */ + @Transactional + public PostDetailResponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + // PostType Enum 변환 + PostType postType = PostType.fromKoreanName(requestDto.getPostType()); + + // DTO -> Entity 변환 + Post post = postMapper.toEntity(requestDto); post.setUser(user); + post.setPostType(postType); + + // 게시글 저장 + Post savedPost = postRepository.save(post); + + // 이미지 처리 + if (requestDto.getImages() != null && !requestDto.getImages().isEmpty()) { + List postImages = processPostImages(requestDto.getImages(), savedPost); + if (!postImages.isEmpty()) { + postImageRepository.saveAll(postImages); + } + } + + return postMapper.toPostDetailDto(savedPost); + } + + /** + * 게시글 수정 (본인만 가능) + */ + @Transactional + public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 작성자 확인 + if (!post.getUser().getId().equals(userId)) { + throw new AccessDeniedException("게시글 수정 권한이 없습니다"); + } + + // PostType 변환 + if (updateDto.getPostType() != null) { + PostType postType = PostType.fromKoreanName(updateDto.getPostType()); + post.setPostType(postType); + } + + // 기본 정보 업데이트 + postMapper.updatePostFromDto(updateDto, post); + + // 이미지 업데이트 처리 + if (updateDto.getImages() != null && !updateDto.getImages().isEmpty()) { + // 기존 이미지 soft delete 처리 + List existingImages = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : existingImages) { + image.softDelete(); + } + + // 새 이미지 추가 + List newImages = processPostImages(updateDto.getImages(), post); + if (!newImages.isEmpty()) { + postImageRepository.saveAll(newImages); + } + } + + return postMapper.toPostDetailDto(post); + } + + /** + * 게시글 삭제 (본인 또는 관리자만 가능) + */ + @Transactional + public void deletePost(Long postId, Long userId, String role) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 작성자 또는 관리자 확인 + boolean isOwner = post.getUser().getId().equals(userId); + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("게시글 삭제 권한이 없습니다"); + } + + // Soft Delete 처리 + post.setDeletedAt(LocalDateTime.now()); + + // 모든 이미지 soft delete 처리 + List images = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : images) { + image.softDelete(); + } + } + + /** + * 이미지 처리를 위한 private 메서드 + */ + private List processPostImages(List images, Post post) { + if (images == null || images.isEmpty()) { + return new ArrayList<>(); + } + + // 이미지 유효성 검증 + validateImages(images); + + // 이미지 업로드 및 엔티티 변환 + try { + List uploadedUrls = s3BucketService.upload(images, "posts/" + post.getId()); + return postImageMapper.toEntityList(uploadedUrls, post); + } catch (Exception e) { + log.error("이미지 업로드 실패: ", e); + throw new GlobalException(FILE_UPLOAD_FAILED); + } + } + + /** + * 이미지 유효성 검증 + */ + private void validateImages(List images) { + // 이미지 파일 개수 제한 + if (images.size() > MAX_FILES_COUNT) { + throw new GlobalException(FILE_COUNT_EXCEEDED); + } + + for (MultipartFile image : images) { + // 파일 크기 검증 + if (image.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(FILE_SIZE_EXCEEDED); + } + + // 파일 타입 검증 + String contentType = image.getContentType(); + if (contentType == null || !ALLOWED_TYPES.contains(contentType)) { + throw new GlobalException(INVALID_FILE_TYPE); + } + } + } - PublicService publicService = new PublicService(); - publicService.setId(postDto.getPublicServiceId()); - post.setPublicService(publicService); - - post.setTitle(postDto.getTitle()); - post.setContent(postDto.getContent()); - post.setPostType(PostType.valueOf(postDto.getPostType())); - post.setDeletedAt(postDto.getDeletedAt()); - post.setServiceUrl(postDto.getServiceUrl()); - post.setRecommendCnt(postDto.getRecommendCnt()); - post.setViewCount(postDto.getViewCount()); - post.setUrlTitle(postDto.getUrlTitle()); - post.setUrlPath(postDto.getUrlPath()); - post.setTags(postDto.getTags()); - post.setCategoryId(postDto.getCategoryId()); // 추가됨 - Post updatedPost = postRepository.save(post); - return postMapper.toDto(updatedPost); + /** + * 사용자가 작성한 게시글 목록 조회 + */ + public Page getPostsByUserId(Long userId, Pageable pageable) { + return postRepository.findByUserIdAndDeletedAtIsNull(userId, pageable) + .map(postMapper::toMyPostListDto); } - public void deletePost(Long id) { - postRepository.deleteById(id); + /** + * 사용자가 추천한 게시글 목록 조회 + */ + public Page getRecommendedPostsByUserId(Long userId, Pageable pageable) { + return postRepository.findByRecommendsUserIdAndDeletedAtIsNull(userId, pageable) + .map(postMapper::toMyPostListDto); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java index ec24017..7cfd76c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java @@ -1,8 +1,8 @@ package com.hyetaekon.hyetaekon.publicservice.controller; +import com.hyetaekon.hyetaekon.publicservice.dto.FilterOptionDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceDetailResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; -import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; import jakarta.validation.constraints.Max; @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @Validated @@ -34,7 +35,7 @@ public ResponseEntity> getAllServices( @RequestParam(required = false) List familyTypes, @RequestParam(required = false) List categories, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size) { + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(50) int size) { Long userId = authenticateUser.authenticateUserId(); @@ -42,8 +43,13 @@ public ResponseEntity> getAllServices( sort, specialGroups, familyTypes, categories, PageRequest.of(page, size), userId)); } + @GetMapping("/filters") + public ResponseEntity>> getFilterOptions() { + return ResponseEntity.ok(publicServiceHandler.getFilterOptions()); + } + // 서비스 분야별 공공서비스 목록 조회 - @GetMapping("/category/{category}") + /*@GetMapping("/category/{category}") public ResponseEntity> getServicesByCategory ( @PathVariable("category") String categoryName, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, @@ -52,11 +58,11 @@ public ResponseEntity> getServicesByCategory ServiceCategory category = publicServiceHandler.getServiceCategory(categoryName); return ResponseEntity.ok(publicServiceHandler.getServicesByCategory(category, PageRequest.of(page, size), userId)); - } + }*/ // 공공서비스 상세 조회 @GetMapping("/detail/{serviceId}") - public ResponseEntity getServiceDetail (@PathVariable("serviceId") Long serviceId) { + public ResponseEntity getServiceDetail (@PathVariable("serviceId") String serviceId) { Long userId = authenticateUser.authenticateUserId(); return ResponseEntity.ok(publicServiceHandler.getServiceDetail(serviceId, userId)); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java deleted file mode 100644 index 5d44241..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; - -import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/services/matched") -@RequiredArgsConstructor -public class PublicServiceMatchedController { - - -} 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 68e14f7..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,29 +1,58 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +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.ServiceSearchHandler; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; + +import java.util.List; @Validated @RestController -@RequestMapping("/api/services/search") +@RequestMapping("/api/mongo/services") @RequiredArgsConstructor public class SearchInfoController { - - // 검색(키워드 + 서비스 조건, 분야 선택) - - - // 자동완성 - - - // 검색 기록 전체 조회 - - - // 검색 기록 삭제 - - - // 검색 기록 전체 삭제 - - -} + private final ServiceSearchHandler searchService; + private final AuthenticateUser authenticateUser; + + // 검색 API (로그인/비로그인 통합) + @GetMapping("/search") + public ResponseEntity> searchServices( + @RequestParam(name = "searchTerm", required = false, defaultValue = "") String searchTerm, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(50) int size + ) { + // 검색 조건 생성 + ServiceSearchCriteriaDto searchCriteria = ServiceSearchCriteriaDto.builder() + .searchTerm(searchTerm) + .pageable(PageRequest.of(page, size)) + .build(); + + // 사용자 인증 여부 확인 + Long userId = authenticateUser.authenticateUserId(); + + // 인증된 사용자면 맞춤 검색, 아니면 기본 검색 + if (userId != 0L) { + return ResponseEntity.ok(searchService.searchPersonalizedServices(searchCriteria, userId)); + } else { + return ResponseEntity.ok(searchService.searchServices(searchCriteria)); + } + } + + // 자동완성 API + @GetMapping("/search/autocomplete") + public ResponseEntity> getAutocompleteResults( + @RequestParam(name = "word") String word + ) { + return ResponseEntity.ok(searchService.getAutocompleteResults(word)); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..fe59802 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -0,0 +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/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/dto/FilterOptionDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java new file mode 100644 index 0000000..594dbe5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java @@ -0,0 +1,11 @@ +package com.hyetaekon.hyetaekon.publicservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FilterOptionDto { + private String code; + private String label; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java index c48c6eb..09a67d3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java @@ -11,7 +11,7 @@ @Builder @ToString public class PublicServiceDetailResponseDto { - private Long publicServiceId; + private String publicServiceId; private String serviceName; private String servicePurpose; // 서비스 목적 diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java index 1a217de..fade66a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java @@ -11,7 +11,7 @@ @Builder @ToString public class PublicServiceListResponseDto { - private Long publicServiceId; + private String publicServiceId; private String serviceName; private String summaryPurpose; diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java new file mode 100644 index 0000000..4642d69 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java @@ -0,0 +1,39 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import lombok.Getter; +import lombok.Builder; +import org.springframework.data.domain.Pageable; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Getter +@Builder +public class ServiceSearchCriteriaDto { + private final String searchTerm; // 검색어 + private final List userInterests; // 사용자 관심사 + private final String userGender; // 사용자 성별 + private final Integer userAge; // 사용자 나이 + private final String userJob; // 사용자 직종 + private final String userIncomeLevel; // 사용자 소득수준 + private final Pageable pageable; + + // 사용자 정보 추가 메서드 + public ServiceSearchCriteriaDto withUserInfo( + List userInterests, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + return ServiceSearchCriteriaDto.builder() + .searchTerm(this.searchTerm) + .userInterests(userInterests) + .userGender(userGender) + .userAge(userAge) + .userIncomeLevel(userIncomeLevel) + .userJob(userJob) + .pageable(this.pageable) + .build(); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java new file mode 100644 index 0000000..74d6cc6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java @@ -0,0 +1,30 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; +import lombok.AllArgsConstructor; +import lombok.*; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ServiceSearchResultDto { + private final List results; + private final long total; + private final int currentPage; + private final int totalPages; + private final boolean hasNext; + + public static ServiceSearchResultDto of(List results, long total, Pageable pageable) { + int totalPages = (int) Math.ceil((double) total / pageable.getPageSize()); + return ServiceSearchResultDto.builder() + .results(results) + .total(total) + .currentPage(pageable.getPageNumber()) + .totalPages(totalPages) + .hasNext(pageable.getPageNumber() + 1 < totalPages) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java index 5e8057f..6bdbb99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -16,75 +16,75 @@ @AllArgsConstructor public class PublicService { @Id - private Long id; + private String id; @Column(name = "service_name", nullable = false, length = 255) private String serviceName; // 서비스명 // 서비스 분야 - 카테고리 + 해시태그 @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "public_category", nullable = false) private ServiceCategory serviceCategory; // 서비스 분야 @Column(name = "summary_purpose", columnDefinition = "TEXT") private String summaryPurpose; // 서비스 목적 요약 - @Column(name = "governing_agency", nullable = false, length = 100) + @Column(name = "governing_agency", length = 100) private String governingAgency; // 소관기관명 - @Column(name = "department", nullable = false, length = 100) + @Column(name = "department", length = 100) private String department; // 부서명 - @Column(name = "user_type", nullable = false, length = 50) + @Column(name = "user_type", length = 50) private String userType; // 사용자 구분 // 지원 대상 필드 - @Column(name = "support_target", nullable = false, columnDefinition = "TEXT") + @Column(name = "support_target", columnDefinition = "TEXT") private String supportTarget; // 지원 대상 - @Column(name = "selection_criteria", nullable = false, columnDefinition = "TEXT") + @Column(name = "selection_criteria", columnDefinition = "TEXT") private String selectionCriteria; // 선정 기준 // 지원 관련 필드 - @Column(name = "service_purpose", nullable = false, columnDefinition = "TEXT") + @Column(name = "service_purpose", columnDefinition = "TEXT") private String servicePurpose; // 서비스 목적 - @Column(name = "support_detail", nullable = false, columnDefinition = "TEXT") + @Column(name = "support_detail", columnDefinition = "TEXT") private String supportDetail; // 지원 내용 - @Column(name = "support_type", nullable = false, length = 100) + @Column(name = "support_type", length = 100) private String supportType; // 지원 유형 // 신청 내용 필드 - @Column(name = "application_method", nullable = false, columnDefinition = "TEXT") + @Column(name = "application_method", columnDefinition = "TEXT") private String applicationMethod; // 신청 방법(상세) - @Column(name = "application_deadline", nullable = false, columnDefinition = "TEXT") + @Column(name = "application_deadline", columnDefinition = "TEXT") private String applicationDeadline; // 신청 기한(상세) // 추가정보 필드 - @Column(name = "required_documents", columnDefinition = "TEXT") - private String requiredDocuments; // 구비 서류 +// @Column(name = "required_documents", columnDefinition = "TEXT") +// private String requiredDocuments; // 구비 서류 - @Column(name = "contact_info", length = 255) + @Column(name = "contact_info", columnDefinition = "TEXT") private String contactInfo; // 문의처 @Column(name = "online_application_url", columnDefinition = "TEXT") private String onlineApplicationUrl; // 온라인 경로 url - @Column(name = "related_laws", columnDefinition = "TEXT") - private String relatedLaws; // 관련 법률 +// @Column(name = "related_laws", columnDefinition = "TEXT") +// private String relatedLaws; // 관련 법률 // 지원조건 필드 - 유저 정보 비교용 - @Column(name = "target_gender_male", nullable = false) + @Column(name = "target_gender_male") private String targetGenderMale; - @Column(name = "target_gender_Female", nullable = false) + @Column(name = "target_gender_female") private String targetGenderFemale; @Column(name = "target_age_start") @@ -118,19 +118,23 @@ public class PublicService { private Integer bookmarkCnt = 0; - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List specialGroups = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List familyTypes = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List occupations = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List businessTypes = new ArrayList<>(); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java index 68e5dd3..e3896b6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java @@ -15,7 +15,11 @@ public enum ServiceCategory { EMPLOYMENT_STARTUP("고용·창업"), HEALTH_MEDICAL("보건·의료"), CULTURE_ENVIRONMENT("문화·환경"), - LIFE_STABILITY("생활안정"); + LIFE_STABILITY("생활안정"), + PROTECTION_CARE("보호·돌봄"), + PREGNANCY_CHILDBIRTH("임신·출산"), + OTHER("기타"); + @JsonValue private final String type; diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java new file mode 100644 index 0000000..bfb6a2a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java @@ -0,0 +1,16 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 + FILTER_OPTIONS("filterOptions", 24, 50), // 필터 옵션 캐시 (하루 유지) + MATCHED_SERVICES("matchedServices", 2, 100); // 맞춤 서비스 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java new file mode 100644 index 0000000..e67b89c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java @@ -0,0 +1,38 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +public enum IncomeLevel { + VERY_LOW("0-50%", "LOW"), + LOW("51-75%", "MIDDLE_LOW"), + MEDIUM("76-100%", "MIDDLE"), + HIGH("101-200%", "MIDDLE_HIGH"), + VERY_HIGH("200%+", "HIGH"), + ANY("ANY", "ANY"); // 모든 소득수준 허용 + + private final String percentageRange; + private final String code; + + IncomeLevel(String percentageRange, String code) { + this.percentageRange = percentageRange; + this.code = code; + } + + // 퍼센트 범위에서 IncomeLevel 찾기 + public static IncomeLevel findByPercentageRange(String percentageRange) { + for (IncomeLevel level : values()) { + if (level.percentageRange.equals(percentageRange)) { + return level; + } + } + return null; + } + + // 코드에서 IncomeLevel 찾기 + public static IncomeLevel findByCode(String code) { + for (IncomeLevel level : values()) { + if (level.code.equals(code)) { + return level; + } + } + return null; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java new file mode 100644 index 0000000..50b965f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java @@ -0,0 +1,34 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "service_info") // 실제 몽고 DB 컬렉션 이름 +public class ServiceInfo { + private String publicServiceId; + private String serviceName; + private String summaryPurpose; + + // 해시태그 + private String serviceCategory; // 서비스 분야 + private List specialGroup; // 특수 대상 그룹 + private List familyType; // 가구 형태 + + private List occupations; + private List businessTypes; + + // Support conditions fields + private String targetGenderMale; + private String targetGenderFemale; + private Integer targetAgeStart; + private Integer targetAgeEnd; + private String incomeLevel; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java index 5f910f0..cd6982c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java @@ -12,6 +12,9 @@ @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PublicServiceMapper { @Mapping(source = "id", target = "publicServiceId") + @Mapping(target = "serviceCategory", expression = "java(publicService.getServiceCategory().getType())") + @Mapping(target = "specialGroup", expression = "java(publicService.getSpecialGroups().stream().map(sg -> sg.getSpecialGroupEnum().getType()).collect(java.util.stream.Collectors.toList()))") + @Mapping(target = "familyType", expression = "java(publicService.getFamilyTypes().stream().map(ft -> ft.getFamilyTypeEnum().getType()).collect(java.util.stream.Collectors.toList()))") PublicServiceListResponseDto toListDto(PublicService publicService); @Mapping(source = "id", target = "publicServiceId") diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java new file mode 100644 index 0000000..2a46e27 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.publicservice.mapper.mongodb; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ServiceInfoMapper { + + @Mapping(source = "specialGroup", target = "specialGroup") + @Mapping(source = "familyType", target = "familyType") + PublicServiceListResponseDto toDto(ServiceInfo serviceInfo); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java index 54c4701..c7e436e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -16,21 +16,20 @@ import java.util.Optional; @Repository -public interface PublicServiceRepository extends JpaRepository { - Page findByServiceCategory(ServiceCategory category, Pageable pageable); +public interface PublicServiceRepository extends JpaRepository { List findTop6ByOrderByBookmarkCntDesc(); - Optional findById(long serviceId); + Optional findById(String serviceId); - int deleteByIdNotIn(List Ids); + int deleteByIdNotIn(List Ids); @Query("SELECT DISTINCT ps FROM PublicService ps " + "LEFT JOIN ps.specialGroups sg " + "LEFT JOIN ps.familyTypes ft " + "WHERE (:#{#categories == null || #categories.isEmpty()} = true OR ps.serviceCategory IN :categories) " + - "AND (:#{#specialGroupEnums == null || #specialGroupEnums.isEmpty()} = true OR sg.specialGroupEnum IN :specialGroupEnums) " + - "AND (:#{#familyTypeEnums == null || #familyTypeEnums.isEmpty()} = true OR ft.familyTypeEnum IN :familyTypeEnums)") + "AND (:#{#specialGroupEnums == null || #specialGroupEnums.isEmpty()} = true OR (sg IS NOT NULL AND sg.specialGroupEnum IN :specialGroupEnums)) " + + "AND (:#{#familyTypeEnums == null || #familyTypeEnums.isEmpty()} = true OR (ft IS NOT NULL AND ft.familyTypeEnum IN :familyTypeEnums))") Page findWithFilters( @Param("categories") List categories, @Param("specialGroupEnums") List specialGroupEnums, @@ -41,5 +40,4 @@ Page findWithFilters( // 사용자의 북마크 공공서비스 목록 페이지 @Query("SELECT p FROM PublicService p JOIN p.bookmarks b WHERE b.user.id = :userId ORDER BY b.createdAt DESC") Page findByBookmarks_User_Id(@Param("userId") Long userId, Pageable pageable); - } 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/repository/mongodb/ServiceSearchClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java new file mode 100644 index 0000000..e8693bd --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -0,0 +1,386 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.Pageable; +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 com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ServiceSearchClient { + private final MongoTemplate mongoTemplate; + private static final String SEARCH_INDEX = "searchIndex"; + private static final String AUTOCOMPLETE_INDEX = "serviceAutocompleteIndex"; + 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, + score: {$meta: 'searchScore'} + } + }"""; + + public ServiceSearchResultDto search(ServiceSearchCriteriaDto criteria) { + List operations = new ArrayList<>(); + + // 검색 쿼리 추가 + operations.add(context -> Document.parse(buildSearchQuery(criteria))); + + // 프로젝션 추가 + operations.add(context -> Document.parse(PROJECT_STAGE)); + + // 페이징 처리 + operations.add(context -> Document.parse(buildFacetStage(criteria.getPageable()))); + + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation(operations), + COLLECTION_NAME, + Document.class + ); + + return processResults(results, criteria.getPageable()); + } + + private String buildSearchQuery(ServiceSearchCriteriaDto criteria) { + List shouldClauses = new ArrayList<>(); + String mustClause = buildUserMustClause(criteria); + + // 검색어 관련 조건 추가 + if (StringUtils.hasText(criteria.getSearchTerm())) { + addSearchTermClauses(shouldClauses, criteria.getSearchTerm()); + } + + // 사용자 관심사 관련 조건 추가 + if (criteria.getUserInterests() != null && !criteria.getUserInterests().isEmpty()) { + for (String interest : criteria.getUserInterests()) { + shouldClauses.add(createSearchClause("serviceCategory", interest, 2.5f, 0)); + shouldClauses.add(createSearchClause("specialGroup", interest, 2.5f, 0)); + shouldClauses.add(createSearchClause("familyType", interest, 2.5f, 0)); + } + } + + // 소득 수준 일치 가산점 (should 조건) + addUserMatchBoosts(shouldClauses, criteria); + + if (StringUtils.hasText(criteria.getUserIncomeLevel())) { + shouldClauses.add(createSearchClause("incomeLevel", criteria.getUserIncomeLevel(), 2.8f, 0)); + shouldClauses.add(createSearchClause("incomeLevel", "ANY", 1.0f, 0)); + } + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + // must 조건이 있으면 추가, 없으면 생략 + String compoundQuery = """ + compound: { + should: %s%s + } + """.formatted(shouldClausesStr, mustClause); + + return """ + { + $search: { + index: '%s', + %s + } + }""".formatted(SEARCH_INDEX, compoundQuery); + } + + private void addSearchTermClauses(List clauses, String searchTerm) { + // 서비스명 검색 + clauses.add(createSearchClause("serviceName", searchTerm, 5.0f, 1)); + clauses.add(createSearchClause("serviceName", searchTerm, 4.5f, 2)); + + // 요약 검색 + clauses.add(createSearchClause("summaryPurpose", searchTerm, 3.5f, 0)); + + // 서비스 분야 검색 + clauses.add(createSearchClause("serviceCategory", searchTerm, 4.5f, 1)); + + // 특수그룹 검색 + clauses.add(createSearchClause("specialGroup", searchTerm, 4.0f, 1)); + + // 가족유형 검색 + clauses.add(createSearchClause("familyType", searchTerm, 4.0f, 1)); + + // 직업 검색 + clauses.add(createSearchClause("occupations", searchTerm, 3.0f, 0)); + + // 사업자 유형 검색 + clauses.add(createSearchClause("businessTypes", searchTerm, 3.0f, 0)); + + // 정규식 전방 일치 검색 (서비스명) + clauses.add(""" + {regex: { + query: '%s.*', + path: 'serviceName', + allowAnalyzedField: true, + score: {boost: {value: 4.0}} + }}""".formatted(searchTerm)); + } + + private String buildUserMustClause(ServiceSearchCriteriaDto criteria) { + List mustClauses = new ArrayList<>(); + + // 성별 필수 조건 + if (StringUtils.hasText(criteria.getUserGender())) { + String genderField = "MALE".equalsIgnoreCase(criteria.getUserGender()) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } + ] + } + } + """.formatted(genderField, genderField)); + + } + + // 나이 필수 조건 + if (criteria.getUserAge() != null) { + int age = criteria.getUserAge(); + + // 대상 나이 범위가 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 void addUserMatchBoosts(List clauses, ServiceSearchCriteriaDto criteria) { + // 직업 일치 가산점 + if (StringUtils.hasText(criteria.getUserJob())) { + String userJob = criteria.getUserJob(); + + // Occupation 필드와 일치 시 가산점 + clauses.add(createSearchClause("occupations", userJob, 3.0f, 0)); + + // BusinessType 필드와 일치 시 가산점 + clauses.add(createSearchClause("businessTypes", userJob, 3.0f, 0)); + } + + // 소득 수준 일치 가산점 + if (StringUtils.hasText(criteria.getUserIncomeLevel())) { + String userIncomeLevel = criteria.getUserIncomeLevel(); + + // 1. 정확히 일치하는 소득수준에 높은 가산점 + clauses.add(""" + {text: { + query: '%s', + path: 'incomeLevel', + score: {boost: {value: 2.8}} + }}""".formatted(userIncomeLevel)); + + // 2. ANY 값은 모든 소득수준에 매칭 가능 + clauses.add(""" + {text: { + query: 'ANY', + path: 'incomeLevel', + score: {boost: {value: 1.0}} + }}"""); + + // 3. 사용자 소득수준보다 낮은 범위도 포함 (범위별 가산점) + addIncomeLevelRangeBoosts(clauses, userIncomeLevel); + } + } + + // 소득수준 범위에 따른 가산점 추가 + private void addIncomeLevelRangeBoosts(List clauses, String userIncomeLevel) { + switch (userIncomeLevel) { + case "HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_HIGH", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 1.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.0f, 0)); + break; + case "MIDDLE_HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE_LOW": + clauses.add(createSearchClause("incomeLevel", "LOW", 2.0f, 0)); + break; + default: + break; + } + } + + private String createSearchClause(String path, String query, float boost, int maxEdits) { + return """ + {text: { + query: '%s', + path: '%s', + score: {boost: {value: %.1f}}%s + }}""".formatted( + query, + path, + boost, + maxEdits > 0 ? ", fuzzy: {maxEdits: " + maxEdits + "}" : "" + ); + } + + private String buildFacetStage(Pageable pageable) { + return """ + { + $facet: { + results: [{$skip: %d}, {$limit: %d}], + total: [{$count: 'count'}] + } + }""".formatted(pageable.getOffset(), pageable.getPageSize()); + } + + private ServiceSearchResultDto processResults(AggregationResults results, Pageable pageable) { + Document result = results.getUniqueMappedResult(); + if (result == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List resultDocs = result.get("results", List.class); + List totalDocs = result.get("total", List.class); + + if (resultDocs == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List searchResults = resultDocs.stream() + .map(doc -> mongoTemplate.getConverter().read(ServiceInfo.class, doc)) + .toList(); + + long total = 0L; + if (totalDocs != null && !totalDocs.isEmpty()) { + Number count = totalDocs.getFirst().get("count", Number.class); + total = count != null ? count.longValue() : 0L; + } + + // 중복 제거: publicServiceId가 같은 경우 하나만 유지 + Map uniqueResults = new LinkedHashMap<>(); + for (ServiceInfo info : searchResults) { + uniqueResults.putIfAbsent(info.getPublicServiceId(), info); + } + + List dedupedResults = new ArrayList<>(uniqueResults.values()); + + // 중복 제거 로깅 + int removedDuplicates = searchResults.size() - dedupedResults.size(); + if (removedDuplicates > 0) { + log.warn("검색 결과에서 중복된 항목 {}개가 제거되었습니다.", removedDuplicates); + } + + return ServiceSearchResultDto.of(dedupedResults, total, pageable); + } + + // 검색어 자동완성 + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + + return mongoTemplate.aggregate( + Aggregation.newAggregation( + context -> Document.parse(""" + { + $search: { + index: '%s', + autocomplete: { + query: '%s', + path: 'serviceName', + fuzzy: {maxEdits: 1} + } + } + }""".formatted(AUTOCOMPLETE_INDEX, word)), + Aggregation.project("serviceName"), + Aggregation.limit(8) + ), + COLLECTION_NAME, + Document.class + ) + .getMappedResults() + .stream() + .map(doc -> doc.getString("serviceName")) + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 0a59a48..3f4eaf2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -3,6 +3,7 @@ import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; import com.hyetaekon.hyetaekon.common.exception.ErrorCode; import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.publicservice.dto.FilterOptionDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceDetailResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.entity.FamilyTypeEnum; @@ -13,6 +14,8 @@ import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; import com.hyetaekon.hyetaekon.publicservice.util.PublicServiceValidate; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -21,13 +24,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class PublicServiceHandler { private final PublicServiceRepository publicServiceRepository; private final PublicServiceMapper publicServiceMapper; @@ -35,7 +36,7 @@ public class PublicServiceHandler { private final BookmarkRepository bookmarkRepository; // 서비스분야별 서비스목록 조회(페이지) - public Page getServicesByCategory(ServiceCategory category, Pageable pageable, Long userId) { + /*public Page getServicesByCategory(ServiceCategory category, Pageable pageable, Long userId) { Page services = publicServiceRepository.findByServiceCategory(category, pageable); return services.map(service -> { @@ -46,13 +47,22 @@ public Page getServicesByCategory(ServiceCategory } return dto; }); - } + }*/ + + /* public ServiceCategory getServiceCategory(String categoryName) { + return publicServiceValidate.validateServiceCategory(categoryName); + }*/ // 서비스 상세 조회 @Transactional - public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long userId) { + public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long userId) { PublicService service = publicServiceValidate.validateServiceById(serviceId); + // 필수 필드가 null인지 검증하는 로직 추가 + if (publicServiceValidate.isDetailInformationIncomplete(service)) { + throw new GlobalException(ErrorCode.INCOMPLETE_SERVICE_DETAIL); + } + // 조회수 증가 service.updateViewsUp(); publicServiceRepository.save(service); @@ -67,17 +77,16 @@ public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long user return dto; } - // 인기 서비스 목록 조회(6개 고정) + // 인기 서비스 목록 조회(6개 고정) - 캐싱적용 + @Transactional(readOnly = true) public List getPopularServices(Long userId) { // 북마크 수 기준으로 상위 6개 서비스 조회 - List services = publicServiceRepository.findTop6ByOrderByBookmarkCntDesc(); - - return services.stream() + return publicServiceRepository.findTop6ByOrderByBookmarkCntDesc().stream() .map(service -> { PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); // 로그인한 사용자는 북마크 여부 확인 - if (userId == 0L) { + if (userId != 0L) { dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); } return dto; @@ -85,11 +94,8 @@ public List getPopularServices(Long userId) { .collect(Collectors.toList()); } - public ServiceCategory getServiceCategory(String categoryName) { - return publicServiceValidate.validateServiceCategory(categoryName); - } - // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) + @Transactional(readOnly = true) public Page getAllServices( String sort, List specialGroups, @@ -99,18 +105,17 @@ public Page getAllServices( Long userId) { // 정렬 기준 설정 (기본값: 가나다순) - Sort.Direction direction = Sort.Direction.ASC; - String sortField = "serviceName"; + Sort sorts = Sort.by(Sort.Order.asc("serviceName")); if (sort != null) { switch (sort.toLowerCase()) { case "bookmark": - sortField = "bookmarkCnt"; - direction = Sort.Direction.DESC; + // 북마크 수 기준 내림차순 정렬, 동일하면 서비스명 오름차순 + sorts = Sort.by(Sort.Order.desc("bookmarkCnt"), Sort.Order.asc("serviceName")); break; case "view": - sortField = "views"; - direction = Sort.Direction.DESC; + // 조회수 기준 내림차순 정렬, 동일하면 서비스명 오름차순 + sorts = Sort.by(Sort.Order.desc("views"), Sort.Order.asc("serviceName")); break; default: // 기본 가나다순 유지 @@ -122,7 +127,7 @@ public Page getAllServices( PageRequest pageRequest = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), - Sort.by(direction, sortField) + sorts ); // 필터링 조건에 따른 서비스 조회 @@ -142,10 +147,16 @@ public Page getAllServices( List specialGroupEnums = new ArrayList<>(); if (specialGroups != null) { for (String group : specialGroups) { - try { - SpecialGroupEnum enumValue = SpecialGroupEnum.valueOf(group); - specialGroupEnums.add(enumValue); - } catch (IllegalArgumentException e) { + // type 값으로 Enum 찾기 + boolean found = false; + for (SpecialGroupEnum enumValue : SpecialGroupEnum.values()) { + if (enumValue.getType().equals(group)) { + specialGroupEnums.add(enumValue); + found = true; + break; + } + } + if (!found) { throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); } } @@ -154,10 +165,16 @@ public Page getAllServices( List familyTypeEnums = new ArrayList<>(); if (familyTypes != null) { for (String type : familyTypes) { - try { - FamilyTypeEnum enumValue = FamilyTypeEnum.valueOf(type); - familyTypeEnums.add(enumValue); - } catch (IllegalArgumentException e) { + // type 값으로 Enum 찾기 + boolean found = false; + for (FamilyTypeEnum enumValue : FamilyTypeEnum.values()) { + if (enumValue.getType().equals(type)) { + familyTypeEnums.add(enumValue); + found = true; + break; + } + } + if (!found) { throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); } } @@ -184,7 +201,42 @@ public Page getAllServices( }); } - public Page getBookmarkedServices(Long userId, Pageable pageable) { + // 필터 옵션 조회 (캐싱 적용) + @Transactional(readOnly = true) + @Cacheable(value = "filterOptions") + public Map> getFilterOptions() { + Map> filterOptions = new HashMap<>(); + + // 서비스 분야 (카테고리) 옵션 + List categoryOptions = Arrays.stream(ServiceCategory.values()) + .map(category -> new FilterOptionDto(category.name(), category.getType())) + .collect(Collectors.toList()); + filterOptions.put("categories", categoryOptions); + + // 특수 그룹 (가구형태) 옵션 + List specialGroupOptions = Arrays.stream(SpecialGroupEnum.values()) + .map(group -> new FilterOptionDto(group.name(), group.getType())) + .collect(Collectors.toList()); + filterOptions.put("specialGroups", specialGroupOptions); + + // 가족 유형 (가구상황) 옵션 + List familyTypeOptions = Arrays.stream(FamilyTypeEnum.values()) + .map(type -> new FilterOptionDto(type.name(), type.getType())) + .collect(Collectors.toList()); + filterOptions.put("familyTypes", familyTypeOptions); + + return filterOptions; + } + + // 필터 옵션 캐시 무효화 - Enum이 변경될 때 + @Transactional + @CacheEvict(value = "filterOptions", allEntries = true) + public void refreshFilterOptions() { + } + + // 내가 북마크한 서비스 목록 조회 + @Transactional(readOnly = true) + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); List serviceDtos = bookmarkedServices.getContent().stream() @@ -197,5 +249,6 @@ public Page getBookmarkedServices(Long userId, Pag return new PageImpl<>(serviceDtos, pageable, bookmarkedServices.getTotalElements()); - } + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java new file mode 100644 index 0000000..8c5e4d7 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java @@ -0,0 +1,120 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.entity.BusinessTypeEnum; +import com.hyetaekon.hyetaekon.publicservice.entity.OccupationEnum; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class IncomeEstimationHandler { + + public String determineIncomeLevelFromJob(String job) { + if (job == null || job.isEmpty()) { + return "MIDDLE"; // 기본값 + } + + // OccupationEnum에서 일치하는 항목 찾기 + for (OccupationEnum occupation : OccupationEnum.values()) { + if (occupation.getType().equals(job)) { + return getIncomeByOccupation(occupation); + } + } + + // BusinessTypeEnum에서 일치하는 항목 찾기 + for (BusinessTypeEnum businessType : BusinessTypeEnum.values()) { + if (businessType.getType().equals(job)) { + return getIncomeByBusinessType(businessType); + } + } + + // 일치하는 것이 없으면 일반 직업 분류로 추정 + return getIncomeByGenericJob(job); + } + + private String getIncomeByOccupation(OccupationEnum occupation) { + switch (occupation) { + case IS_ELEMENTARY_STUDENT: + case IS_MIDDLE_SCHOOL_STUDENT: + case IS_HIGH_SCHOOL_STUDENT: + case IS_JOB_SEEKER: + return "LOW"; + + case IS_UNIVERSITY_STUDENT: + return "MIDDLE_LOW"; + + case IS_FARMER: + case IS_FISHERMAN: + case IS_STOCK_BREEDER: + case IS_FORESTER: + return "MIDDLE"; + + case IS_WORKER: + return "MIDDLE_HIGH"; + + default: + return "MIDDLE"; + } + } + + private String getIncomeByBusinessType(BusinessTypeEnum businessType) { + switch (businessType) { + case IS_BUSINESS_HARDSHIP: + return "LOW"; + + case IS_STARTUP_PREPARATION: + case IS_FOOD_INDUSTRY: + return "MIDDLE_LOW"; + + case IS_BUSINESS_OPERATING: + case IS_OTHER_INDUSTRY: + case IS_OTHER_INDUSTRY_TYPE: + return "MIDDLE"; + + case IS_MANUFACTURING_INDUSTRY: + case IS_MANUFACTURING_INDUSTRY_TYPE: + case IS_SMALL_MEDIUM_ENTERPRISE: + return "MIDDLE_HIGH"; + + case IS_INFORMATION_TECHNOLOGY_INDUSTRY: + case IS_ORGANIZATION: + case IS_SOCIAL_WELFARE_INSTITUTION: + return "HIGH"; + + default: + return "MIDDLE"; + } + } + + private String getIncomeByGenericJob(String job) { + // 일반적인 직업 키워드 기반 추정 + String lowercaseJob = job.toLowerCase(); + + if (lowercaseJob.contains("학생") || lowercaseJob.contains("무직") || + lowercaseJob.contains("구직") || lowercaseJob.contains("실업")) { + return "LOW"; + } + + if (lowercaseJob.contains("알바") || lowercaseJob.contains("프리랜서") || + lowercaseJob.contains("인턴")) { + return "MIDDLE_LOW"; + } + + if (lowercaseJob.contains("공무원") || lowercaseJob.contains("직장인") || + lowercaseJob.contains("회사원")) { + return "MIDDLE"; + } + + if (lowercaseJob.contains("전문가") || lowercaseJob.contains("매니저") || + lowercaseJob.contains("관리자")) { + return "MIDDLE_HIGH"; + } + + if (lowercaseJob.contains("대표") || lowercaseJob.contains("임원") || + lowercaseJob.contains("의사") || lowercaseJob.contains("변호사")) { + return "HIGH"; + } + + return "MIDDLE"; // 기본값 + } +} 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/ServiceSearchHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java new file mode 100644 index 0000000..dbaea0f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java @@ -0,0 +1,138 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.searchHistory.Service.SearchHistoryService; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.repository.UserInterestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.repository.mongodb.ServiceSearchClient; +import com.hyetaekon.hyetaekon.publicservice.mapper.mongodb.ServiceInfoMapper; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; + +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ServiceSearchHandler { + private final ServiceSearchClient serviceSearchClient; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + private final SearchHistoryService searchHistoryService; + + // 기본 검색 (비로그인) + public Page searchServices(ServiceSearchCriteriaDto criteria) { + // 검색 조건이 없는 경우 빈 결과 반환 + if (!StringUtils.hasText(criteria.getSearchTerm())) { + return Page.empty(criteria.getPageable()); + } + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(criteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 없이) + return convertToPageResponse(searchResult, null); + } + + // 맞춤 검색 (로그인) + public Page searchPersonalizedServices( + ServiceSearchCriteriaDto criteria, Long userId) { + + // 검색 조건이 없는 경우 빈 결과 반환 + if (!StringUtils.hasText(criteria.getSearchTerm())) { + return Page.empty(criteria.getPageable()); + } else if(StringUtils.hasText(criteria.getSearchTerm())) { // 검색어가 유효하면 검색 기록 저장 + searchHistoryService.saveSearchHistory(userId, criteria.getSearchTerm()); + } + + // 사용자 정보 가져오기 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID입니다.")); + + String userIncomeLevel = incomeEstimationHandler.determineIncomeLevelFromJob(user.getJob()); + + // 사용자 관심사 목록 추출 + List userInterests = userInterestRepository.findByUserId(userId).stream() + .map(UserInterest::getInterest) + .collect(Collectors.toList()); + + // 사용자 정보로 검색 조건 보강 + ServiceSearchCriteriaDto enrichedCriteria = criteria.withUserInfo( + userInterests, + user.getGender(), + calculateAge(user.getBirthAt()), + userIncomeLevel, + user.getJob() + ); + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(enrichedCriteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 포함) + return convertToPageResponse(searchResult, userId); + } + + private Page convertToPageResponse( + ServiceSearchResultDto searchResult, Long userId) { + + List dtoList = searchResult.getResults().stream() + .map(serviceInfo -> { + // Entity를 DTO로 변환 + PublicServiceListResponseDto dto = serviceInfoMapper.toDto(serviceInfo); + + // 사용자 ID가 제공된 경우 북마크 정보 설정 + if (userId != null) { + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId( + userId, serviceInfo.getPublicServiceId())); + } + + return dto; + }) + .collect(Collectors.toList()); + + return new PageImpl<>( + dtoList, + PageRequest.of(searchResult.getCurrentPage(), + searchResult.getResults().isEmpty() ? 10 : searchResult.getResults().size()), + searchResult.getTotal() + ); + } + + // 나이 계산 헬퍼 메서드 + public Integer calculateAge(LocalDate birthDate) { + if (birthDate == null) return null; + return Period.between(birthDate, LocalDate.now()).getYears(); + } + + // 자동완성 기능 - 캐싱 적용 + @Cacheable(value = "serviceAutocomplete", key = "#word", unless = "#result.isEmpty()") + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + return serviceSearchClient.getAutocompleteResults(word); + } + + // 자동완성 캐시 무효화 + @CacheEvict(value = "serviceAutocomplete", allEntries = true) + public void refreshAutocompleteCache() { + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java index c7bb87f..daae86f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java @@ -17,7 +17,7 @@ public class PublicServiceValidate { public final PublicServiceRepository publicServiceRepository; public final UserRepository userRepository; - public PublicService validateServiceById(Long serviceId) { + public PublicService validateServiceById(String serviceId) { return publicServiceRepository.findById(serviceId) .orElseThrow(() -> new GlobalException(ErrorCode.SERVICE_NOT_FOUND_BY_ID)); } @@ -28,10 +28,38 @@ public User validateUserById(Long userId) { } public ServiceCategory validateServiceCategory(String categoryName) { - try { + /*try { return ServiceCategory.valueOf(categoryName); } catch (IllegalArgumentException e) { throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + }*/ + for (ServiceCategory category : ServiceCategory.values()) { + if (category.getType().equals(categoryName)) { + return category; + } } + throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + } + + /** + * 서비스 상세 정보의 완전성 검증 + * @param service 검증할 공공서비스 객체 + * @return 상세 정보가 불완전하면 true, 충분히 완전하면 false 반환 + */ + public boolean isDetailInformationIncomplete(PublicService service) { + // 필수 필드 중 일정 개수 이상 누락된 경우 true 반환 + int nullCount = 0; + + if (service.getServicePurpose() == null || service.getServicePurpose().isEmpty()) nullCount++; + if (service.getSupportTarget() == null || service.getSupportTarget().isEmpty()) nullCount++; + if (service.getSupportDetail() == null || service.getSupportDetail().isEmpty()) nullCount++; + if (service.getSupportType() == null || service.getSupportType().isEmpty()) nullCount++; + if (service.getApplicationMethod() == null || service.getApplicationMethod().isEmpty()) nullCount++; + if (service.getApplicationDeadline() == null || service.getApplicationDeadline().isEmpty()) nullCount++; + if (service.getGoverningAgency() == null || service.getGoverningAgency().isEmpty()) nullCount++; + if (service.getContactInfo() == null || service.getContactInfo().isEmpty()) nullCount++; + + // 필수 필드 중 3개 이상 누락되면 불완전한 데이터로 판단 + return nullCount >= 3; } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java index fb913c0..ad1c3d3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java @@ -1,9 +1,13 @@ package com.hyetaekon.hyetaekon.recommend.controller; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.recommend.service.RecommendService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -11,5 +15,25 @@ @RequiredArgsConstructor public class RecommendController { + private final RecommendService recommendService; + // 북마크 추가 + @PostMapping + public ResponseEntity addBookmark( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + recommendService.addRecommend(postId, customUserDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + // 북마크 제거 + @DeleteMapping + public ResponseEntity removeBookmark( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + recommendService.removeRecommend(postId, customUserDetails.getId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java index bbd312d..a37bf4d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java @@ -1,12 +1,18 @@ package com.hyetaekon.hyetaekon.recommend.service; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; +import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; + @Service @Transactional @RequiredArgsConstructor @@ -15,4 +21,38 @@ public class RecommendService { private final UserRepository userRepository; private final PostRepository postRepository; + public void addRecommend(Long postId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(RECOMMEND_USER_NOT_FOUND)); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new GlobalException(POST_NOT_FOUND_BY_ID)); + + // 이미 북마크가 있는지 확인 + if (recommendRepository.existsByUserIdAndPostId(userId, postId)) { + throw new GlobalException(BOOKMARK_ALREADY_EXISTS); + } + + Recommend recommend = Recommend.builder() + .user(user) + .post(post) + .build(); + + recommendRepository.save(recommend); + + // 북마크 수 증가 + post.incrementRecommendCnt(); + } + + @jakarta.transaction.Transactional + public void removeRecommend(Long postId, Long userId) { + Recommend recommend = recommendRepository.findByUserIdAndPostId(userId, postId) + .orElseThrow(() -> new GlobalException(RECOMMEND_NOT_FOUND)); + + recommendRepository.delete(recommend); + + // 추천수 감소 + Post post = recommend.getPost(); + post.decrementRecommendCnt(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Dto/SearchHistoryDto.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Dto/SearchHistoryDto.java new file mode 100644 index 0000000..fd1b954 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Dto/SearchHistoryDto.java @@ -0,0 +1,25 @@ +package com.hyetaekon.hyetaekon.searchHistory.Dto; + +import com.hyetaekon.hyetaekon.searchHistory.entity.SearchHistory; +import lombok.*; + +import java.time.format.DateTimeFormatter; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchHistoryDto { + private String id; + private String searchTerm; + private String createdAt; + + // Entity -> DTO 변환 + public static SearchHistoryDto from(SearchHistory entity) { + return SearchHistoryDto.builder() + .id(entity.getId()) + .searchTerm(entity.getSearchTerm()) + .createdAt(entity.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Repository/SearchHistoryRepository.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Repository/SearchHistoryRepository.java new file mode 100644 index 0000000..5bbba95 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Repository/SearchHistoryRepository.java @@ -0,0 +1,16 @@ +package com.hyetaekon.hyetaekon.searchHistory.Repository; + +import com.hyetaekon.hyetaekon.searchHistory.entity.SearchHistory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SearchHistoryRepository extends CrudRepository { + // 특정 사용자의 모든 검색 기록 조회 + List findByUserId(Long userId); + + // 특정 사용자의 특정 검색어 기록 삭제 + void deleteByUserIdAndId(Long userId, String id); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java new file mode 100644 index 0000000..130b628 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -0,0 +1,85 @@ +package com.hyetaekon.hyetaekon.searchHistory.Service; + +import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; +import com.hyetaekon.hyetaekon.searchHistory.Repository.SearchHistoryRepository; +import com.hyetaekon.hyetaekon.searchHistory.entity.SearchHistory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Comparator; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchHistoryService { + private final SearchHistoryRepository searchHistoryRepository; + + private static final int MAX_DISPLAY_COUNT = 6; + + /** + * 검색 기록 저장 + */ + @Transactional + public void saveSearchHistory(Long userId, String searchTerm) { + // 검색어가 없거나 빈 문자열이면 저장하지 않음 + if (!StringUtils.hasText(searchTerm) || userId == 0L) { + return; + } + + // 검색어 중복 제거를 위한 기존 검색 기록 확인 + List existingHistories = searchHistoryRepository.findByUserId(userId); + + // 이미 동일한 검색어가 있으면 삭제 + existingHistories.stream() + .filter(history -> history.getSearchTerm().equals(searchTerm)) + .forEach(history -> searchHistoryRepository.deleteById(history.getId())); + + // 새로운 검색 기록 저장 + SearchHistory newHistory = SearchHistory.of(userId, searchTerm); + searchHistoryRepository.save(newHistory); + log.debug("사용자 {} 검색 기록 저장: {}", userId, searchTerm); + } + + /** + * 사용자의 검색 기록 조회 (최신 6개) + */ + @Transactional(readOnly = true) + public List getUserSearchHistories(Long userId) { + List histories = searchHistoryRepository.findByUserId(userId); + + // 최신 순으로 정렬하여 최대 6개 반환 + return histories.stream() + .sorted(Comparator.comparing(SearchHistory::getCreatedAt).reversed()) + .limit(MAX_DISPLAY_COUNT) + .map(SearchHistoryDto::from) + .collect(Collectors.toList()); + } + + /** + * 개별 검색 기록 삭제 + */ + @Transactional + public void deleteSearchHistory(Long userId, String historyId) { + if (!StringUtils.hasText(historyId)) { + return; + } + // 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 + searchHistoryRepository.deleteByUserIdAndId(userId, historyId); + log.debug("사용자 {} 검색 기록 삭제: {}", userId, historyId); + } + + /** + * 사용자의 모든 검색 기록 삭제 + */ + @Transactional + public void deleteAllSearchHistories(Long userId) { + List histories = searchHistoryRepository.findByUserId(userId); + searchHistoryRepository.deleteAll(histories); + log.debug("사용자 {} 검색 기록 전체 삭제", userId); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java new file mode 100644 index 0000000..c2d9c86 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java @@ -0,0 +1,49 @@ +package com.hyetaekon.hyetaekon.searchHistory.controller; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; +import com.hyetaekon.hyetaekon.searchHistory.Service.SearchHistoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/search/history") +@RequiredArgsConstructor +public class SearchHistoryController { + + private final SearchHistoryService searchHistoryService; + + /** + * 현재 로그인한 사용자의 검색 기록 조회 (최신 6개) + */ + @GetMapping + public ResponseEntity> getSearchHistories( + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(searchHistoryService.getUserSearchHistories(userDetails.getId())); + } + + /** + * 특정 검색 기록 삭제 + */ + @DeleteMapping("/{historyId}") + public ResponseEntity deleteSearchHistory(@PathVariable String historyId + , @AuthenticationPrincipal CustomUserDetails userDetails) { + searchHistoryService.deleteSearchHistory(userDetails.getId(), historyId); + return ResponseEntity.ok().build(); + } + + /** + * 모든 검색 기록 삭제 + */ + @DeleteMapping + public ResponseEntity deleteAllSearchHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + searchHistoryService.deleteAllSearchHistories(userDetails.getId()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java new file mode 100644 index 0000000..01de45d --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java @@ -0,0 +1,36 @@ +package com.hyetaekon.hyetaekon.searchHistory.entity; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@RedisHash(value = "searchHistory", timeToLive = 2592000) // 30일 유지 +public class SearchHistory implements Serializable { + @Id + private String id; // userId:timestamp 형태로 구성 + + @Indexed // 인덱싱으로 특정 사용자의 검색 기록 조회 가능 + private Long userId; + + private String searchTerm; + private LocalDateTime createdAt; + + // 팩토리 메서드 + public static SearchHistory of(Long userId, String searchTerm) { + String id = userId + ":" + System.currentTimeMillis(); + return SearchHistory.builder() + .id(id) + .userId(userId) + .searchTerm(searchTerm) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java index 6af4604..a5ec4ec 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java @@ -1,8 +1,11 @@ package com.hyetaekon.hyetaekon.user.controller; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserSuspendRequestDto; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; import com.hyetaekon.hyetaekon.user.service.UserAdminService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,7 +35,7 @@ public ResponseEntity> getAllUsers( */ @PostMapping("/users/{userId}/suspend") public ResponseEntity suspendUser( - @PathVariable Long userId, + @PathVariable("userId") Long userId, @RequestBody UserSuspendRequestDto requestDto) { userAdminService.suspendUser(userId, requestDto); return ResponseEntity.ok().build(); @@ -42,7 +45,7 @@ public ResponseEntity suspendUser( * 정지 해제 */ @PutMapping("/users/{userId}/unsuspend") - public ResponseEntity unsuspendUser(@PathVariable Long userId) { + public ResponseEntity unsuspendUser(@PathVariable("userId") Long userId) { userAdminService.unsuspendUser(userId); return ResponseEntity.ok().build(); } @@ -77,4 +80,42 @@ public ResponseEntity> getUserReports( return ResponseEntity.ok(userAdminService.getUserReports(page, size)); } + /** + * 상태별 신고 내역 조회 + */ + @GetMapping("/users/reports/status/{status}") + public ResponseEntity> getReportsByStatus( + @PathVariable("status") ReportStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getReportsByStatus(status, page, size)); + } + + /** + * 신고 승인 처리 + */ + @PostMapping("/users/reports/{reportId}/resolve") + public ResponseEntity resolveReport( + @PathVariable("reportId") Long reportId, + @RequestParam(defaultValue = "false") boolean suspendUser, + @RequestBody(required = false) UserSuspendRequestDto suspendRequestDto) { + + // 사용자 정지 요청이 있지만 정지 정보가 없는 경우 + if (suspendUser && suspendRequestDto == null) { + throw new GlobalException(ErrorCode.INVALID_REPORT_REQUEST); + } + + userAdminService.resolveReport(reportId, suspendUser, suspendRequestDto); + return ResponseEntity.ok().build(); + } + + /** + * 신고 거부 처리 + */ + @PostMapping("/users/reports/{reportId}/reject") + public ResponseEntity rejectReport(@PathVariable("reportId") Long reportId) { + userAdminService.rejectReport(reportId); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java index 6546a5d..68ab0d6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -1,6 +1,8 @@ package com.hyetaekon.hyetaekon.user.controller; import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.post.dto.MyPostListResponseDto; +import com.hyetaekon.hyetaekon.post.service.PostService; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; import com.hyetaekon.hyetaekon.user.dto.*; @@ -27,6 +29,7 @@ public class UserController { private final UserService userService; private final PublicServiceHandler publicServiceHandler; + private final PostService postService; // 회원 가입 api @PostMapping("/signup") @@ -69,12 +72,12 @@ public ResponseEntity updateMyPassword( @DeleteMapping("/users/me") public ResponseEntity deleteUser( @AuthenticationPrincipal CustomUserDetails customUserDetails, - @RequestBody String deleteReason, + @RequestBody UserDeleteRequestDto deleteRequestDto, @CookieValue(name = "refreshToken", required = false) String refreshToken, @RequestHeader("Authorization") String authHeader ) { String accessToken = authHeader.replace("Bearer ", ""); - userService.deleteUser(customUserDetails.getId(), deleteReason, accessToken, refreshToken); + userService.deleteUser(customUserDetails.getId(), deleteRequestDto.getDeleteReason(), accessToken, refreshToken); return ResponseEntity.noContent().build(); } @@ -89,40 +92,45 @@ public boolean checkDuplicate( } // 북마크한 서비스 목록 조회 - @GetMapping("/users/me/bookmarked") + @GetMapping("/users/me/bookmarked/posts") public ResponseEntity> getBookmarkedServices( @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(publicServiceHandler.getBookmarkedServices( userDetails.getId(), PageRequest.of(page, size)) ); } - /** - * 작성한 게시글 목록 조회 - */ -// @GetMapping("/me/posts") -// @PreAuthorize("hasRole('USER')") -// public ResponseEntity>> getMyPosts( -// @RequestParam(required = false) String postType, -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { -// Page posts = userService.getMyPosts(postType, PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(posts)); -// } -// - /** - * 작성한 댓글 목록 조회 - */ -// @GetMapping("/me/comments") -// @PreAuthorize("hasRole('USER')") -// public ResponseEntity>> getMyComments( -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { -// Page comments = userService.getMyComments(PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(comments)); -// } + /** + * 내가 작성한 게시글 목록 조회 + */ + @GetMapping("/users/me/posts") + public ResponseEntity> getMyPosts( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + Page posts = postService.getPostsByUserId( + userDetails.getId(), PageRequest.of(page, size)); + + return ResponseEntity.ok(posts); + } + + /** + * 내가 추천한 게시글 목록 조회 + */ + @GetMapping("/users/me/recommended/posts") + public ResponseEntity> getMyRecommendedPosts( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + Page posts = postService.getRecommendedPostsByUserId( + userDetails.getId(), PageRequest.of(page, size)); + + return ResponseEntity.ok(posts); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java new file mode 100644 index 0000000..615a7e5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java @@ -0,0 +1,29 @@ +package com.hyetaekon.hyetaekon.user.controller; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.user.dto.UserReportRequestDto; +import com.hyetaekon.hyetaekon.user.service.UserReportService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users/reports") +@RequiredArgsConstructor +public class UserReportController { + private final UserReportService userReportService; + + @PostMapping + public ResponseEntity reportUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UserReportRequestDto reportRequestDto + ) { + Long reporterId = userDetails.getId(); + userReportService.reportUser(reporterId, reportRequestDto); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java new file mode 100644 index 0000000..5873589 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserDeleteRequestDto { + private String deleteReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java new file mode 100644 index 0000000..bddd983 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserReportRequestDto { + @NotNull(message = "신고 대상 사용자 ID는 필수입니다.") + private Long reportedUserId; + + @NotNull(message = "신고 사유는 필수입니다.") + private String reason; + + private String content; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java new file mode 100644 index 0000000..fe214ae --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.user.dto.admin; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserReportProcessDto { + private boolean suspendUser; // 신고 처리 시 사용자 정지 여부 + + // 사용자 정지시 필요한 정보 (suspendUser가 true일 때) + private LocalDateTime suspendStartAt; + private LocalDateTime suspendEndAt; + private String suspendReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java new file mode 100644 index 0000000..34ba972 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.user.entity; + +import lombok.Getter; + +@Getter +public enum ReportStatus { + PENDING("처리 대기중"), + RESOLVED("처리 완료"), + REJECTED("거부됨"); + + private final String description; + + ReportStatus(String description) { + this.description = description; + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java index babb41b..17f79d4 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java @@ -1,6 +1,6 @@ package com.hyetaekon.hyetaekon.user.entity; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import jakarta.persistence.*; diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java index b63b3ac..9f9c66d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java @@ -35,11 +35,22 @@ public class UserReport { private String content; @Column(name = "status", length = 20) - private String status; + @Enumerated(EnumType.STRING) + private ReportStatus status; @Column(name = "created_at") private LocalDateTime createdAt; @Column(name = "processed_at") private LocalDateTime processedAt; + + public void resolve() { + this.status = ReportStatus.RESOLVED; + this.processedAt = LocalDateTime.now(); + } + + public void reject() { + this.status = ReportStatus.REJECTED; + this.processedAt = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java index 7d2b56b..0318822 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java @@ -18,5 +18,6 @@ public interface UserAdminMapper { // UserReport Entity -> 신고 내역 DTO 변환 @Mapping(source = "reporter.nickname", target = "reporterNickname") @Mapping(source = "reported.nickname", target = "reportedNickname") + @Mapping(source = "status.description", target = "status") UserReportResponseDto toReportResponseDto(UserReport userReport); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java index 4a99e23..4302435 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java @@ -1,11 +1,15 @@ package com.hyetaekon.hyetaekon.user.repository; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; import com.hyetaekon.hyetaekon.user.entity.UserReport; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface UserReportRepository extends JpaRepository { - // 기본 CRUD 메서드 사용 + // 상태별 신고 목록 조회 + Page findByStatus(ReportStatus status, Pageable pageable); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java index c175027..8ec0a6b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java @@ -3,8 +3,10 @@ import com.hyetaekon.hyetaekon.common.exception.ErrorCode; import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserReportProcessDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserSuspendRequestDto; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.entity.UserReport; import com.hyetaekon.hyetaekon.user.mapper.UserAdminMapper; @@ -107,7 +109,7 @@ public Page getWithdrawnUsers(int page, int size) { } /** - * 신고 내역 조회 + * 신고 내역 조회 (전체) */ @Transactional(readOnly = true) public Page getUserReports(int page, int size) { @@ -116,5 +118,60 @@ public Page getUserReports(int page, int size) { return reportPage.map(userAdminMapper::toReportResponseDto); } + /** + * 상태별 신고 내역 조회 + */ + @Transactional(readOnly = true) + public Page getReportsByStatus(ReportStatus status, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page reportPage = userReportRepository.findByStatus(status, pageable); + return reportPage.map(userAdminMapper::toReportResponseDto); + } + + /** + * 신고 승인 처리 + */ + @Transactional + public void resolveReport(Long reportId, boolean suspendUser, UserSuspendRequestDto suspendRequestDto) { + UserReport report = userReportRepository.findById(reportId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPORT_NOT_FOUND)); + + // 이미 처리된 신고인지 확인 + if (report.getStatus() != ReportStatus.PENDING) { + throw new GlobalException(ErrorCode.REPORT_ALREADY_PROCESSED); + } + + // 신고 승인 처리 + report.resolve(); + + // 신고당한 사용자 정지 처리 여부 확인 + if (suspendUser && suspendRequestDto != null) { + User reportedUser = report.getReported(); + suspendUser(reportedUser.getId(), suspendRequestDto); + log.info("신고에 따른 사용자 {} 정지 처리 완료", reportedUser.getId()); + } + + userReportRepository.save(report); + log.info("신고 {} 승인 처리 완료", reportId); + } + + /** + * 신고 거부 처리 + */ + @Transactional + public void rejectReport(Long reportId) { + UserReport report = userReportRepository.findById(reportId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPORT_NOT_FOUND)); + + // 이미 처리된 신고인지 확인 + if (report.getStatus() != ReportStatus.PENDING) { + throw new GlobalException(ErrorCode.REPORT_ALREADY_PROCESSED); + } + + report.reject(); + userReportRepository.save(report); + log.info("신고 {} 거부 처리 완료", reportId); + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java new file mode 100644 index 0000000..56e44f4 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java @@ -0,0 +1,54 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.user.dto.UserReportRequestDto; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserReport; +import com.hyetaekon.hyetaekon.user.repository.UserReportRepository; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserReportService { + private final UserRepository userRepository; + private final UserReportRepository userReportRepository; + + // 사용자 신고 + @Transactional + public void reportUser(Long reporterId, UserReportRequestDto reportRequestDto) { + // 신고자 확인 + User reporter = userRepository.findByIdAndDeletedAtIsNull(reporterId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 신고 대상자 확인 + User reported = userRepository.findByIdAndDeletedAtIsNull(reportRequestDto.getReportedUserId()) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 자기 자신 신고 방지 + if (reporter.getId().equals(reported.getId())) { + throw new GlobalException(ErrorCode.CANNOT_REPORT_SELF); + } + + // 신고 내역 생성 및 저장 + UserReport userReport = UserReport.builder() + .reporter(reporter) + .reported(reported) + .reason(reportRequestDto.getReason()) + .content(reportRequestDto.getContent()) + .status(ReportStatus.PENDING) // 대기 상태로 초기화 + .createdAt(LocalDateTime.now()) + .build(); + + userReportRepository.save(userReport); + log.info("사용자 신고 접수 완료 - 신고자: {}, 피신고자: {}", reporter.getId(), reported.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/controller/UserInterestController.java similarity index 84% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/controller/UserInterestController.java index 6e15c52..3de8529 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/controller/UserInterestController.java @@ -1,10 +1,10 @@ -package com.hyetaekon.hyetaekon.UserInterest.controller; +package com.hyetaekon.hyetaekon.userInterest.controller; -import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsResponseDto; -import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsWithSelectionDto; -import com.hyetaekon.hyetaekon.UserInterest.dto.InterestSelectionRequestDto; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterestEnum; -import com.hyetaekon.hyetaekon.UserInterest.service.UserInterestService; +import com.hyetaekon.hyetaekon.userInterest.dto.CategorizedInterestsResponseDto; +import com.hyetaekon.hyetaekon.userInterest.dto.CategorizedInterestsWithSelectionDto; +import com.hyetaekon.hyetaekon.userInterest.dto.InterestSelectionRequestDto; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterestEnum; +import com.hyetaekon.hyetaekon.userInterest.service.UserInterestService; import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsResponseDto.java similarity index 76% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsResponseDto.java index f156cbc..b251e1c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsResponseDto.java @@ -1,8 +1,7 @@ -package com.hyetaekon.hyetaekon.UserInterest.dto; +package com.hyetaekon.hyetaekon.userInterest.dto; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.Setter; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsWithSelectionDto.java similarity index 83% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsWithSelectionDto.java index 907417a..6e60355 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/CategorizedInterestsWithSelectionDto.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.dto; +package com.hyetaekon.hyetaekon.userInterest.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestItemDto.java similarity index 81% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestItemDto.java index f572bda..070e03c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestItemDto.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.dto; +package com.hyetaekon.hyetaekon.userInterest.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestSelectionRequestDto.java similarity index 91% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestSelectionRequestDto.java index a34be5e..7196e3c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/dto/InterestSelectionRequestDto.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.dto; +package com.hyetaekon.hyetaekon.userInterest.dto; import lombok.Getter; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterest.java similarity index 92% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterest.java index 118b63c..b7ace16 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterest.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.entity; +package com.hyetaekon.hyetaekon.userInterest.entity; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java similarity index 89% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java index 92f8296..a738203 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.entity; +package com.hyetaekon.hyetaekon.userInterest.entity; import lombok.Getter; @@ -13,6 +13,8 @@ public enum UserInterestEnum { HEALTH_MEDICAL("보건·의료", "관심주제"), CULTURE_ENVIRONMENT("문화·환경", "관심주제"), LIFE_STABILITY("생활안정", "관심주제"), + PROTECTION_CARE("보호·돌봄", "관심주제"), // 새로 추가된 카테고리 + OTHER("기타", "관심주제"), // SpecialGroup 관련 관심사 IS_MULTI_CULTURAL("다문화가족", "가구형태"), diff --git a/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java new file mode 100644 index 0000000..aa1fd77 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java @@ -0,0 +1,27 @@ +package com.hyetaekon.hyetaekon.userInterest.repository; + +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserInterestRepository extends JpaRepository { + /** + * 사용자 ID에 해당하는 모든 관심사 항목을 조회합니다. + * + * @param userId 사용자 ID + * @return 사용자의 관심사 목록 + */ + List findByUserId(Long userId); + + /** + * 사용자 ID로 관심사 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 관심사 존재 여부 + */ + boolean existsByUserId(Long userId); + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/service/UserInterestService.java similarity index 91% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/service/UserInterestService.java index 84d1a91..204353c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/service/UserInterestService.java @@ -1,10 +1,10 @@ -package com.hyetaekon.hyetaekon.UserInterest.service; +package com.hyetaekon.hyetaekon.userInterest.service; -import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsWithSelectionDto; -import com.hyetaekon.hyetaekon.UserInterest.dto.InterestItemDto; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterestEnum; -import com.hyetaekon.hyetaekon.UserInterest.repository.UserInterestRepository; +import com.hyetaekon.hyetaekon.userInterest.dto.CategorizedInterestsWithSelectionDto; +import com.hyetaekon.hyetaekon.userInterest.dto.InterestItemDto; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterestEnum; +import com.hyetaekon.hyetaekon.userInterest.repository.UserInterestRepository; import com.hyetaekon.hyetaekon.common.exception.ErrorCode; import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.user.entity.User; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7259009..1982e1d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -9,4 +9,4 @@ spring: jwt: access-expired: 1800 # 30분 - refresh-expired: 432000 # 5일 \ No newline at end of file + refresh-expired: 86400 # 1일 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 315d5b6..7fc6fde 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: data: -# mongodb: -# uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&appName=HyetaekOn -# auto-index-creation: true + mongodb: + uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&serverSelectionTimeoutMS=30000&appName=HyetaekOn + auto-index-creation: true redis: port: 6379 timeout: 2000