From 730485c7b7a4860313a4e77b736d3f0325ad24cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Fri, 11 Apr 2025 06:05:34 +0900 Subject: [PATCH 01/41] =?UTF-8?q?fix:=20data=EC=9D=98=20serviceID=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserInterest/entity/UserInterestEnum.java | 2 ++ .../bookmark/controller/BookMarkController.java | 4 ++-- .../bookmark/repository/BookmarkRepository.java | 4 ++-- .../hyetaekon/bookmark/service/BookmarkService.java | 4 ++-- .../hyetaekon/common/config/SecurityConfig.java | 2 +- .../common/converter/ServiceCategoryConverter.java | 12 +++++++++++- .../dto/PublicServiceConditionsDataDto.java | 2 +- .../common/publicdata/dto/PublicServiceDataDto.java | 2 +- .../publicdata/dto/PublicServiceDetailDataDto.java | 2 +- .../service/PublicServiceDataServiceImpl.java | 13 ++++++------- .../controller/PublicServiceController.java | 2 +- .../dto/PublicServiceDetailResponseDto.java | 2 +- .../dto/PublicServiceListResponseDto.java | 2 +- .../publicservice/entity/PublicService.java | 2 +- .../publicservice/entity/ServiceCategory.java | 6 +++++- .../repository/PublicServiceRepository.java | 6 +++--- .../publicservice/service/PublicServiceHandler.java | 2 +- .../publicservice/util/PublicServiceValidate.java | 2 +- 18 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java index 92f8296..815a1b2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java @@ -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/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..fb9d821 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java @@ -21,7 +21,7 @@ public class BookmarkService { private final UserRepository userRepository; private final PublicServiceRepository publicServiceRepository; - 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)); @@ -45,7 +45,7 @@ public void addBookmark(Long serviceId, Long userId) { } @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)); 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..233d20c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -50,7 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((auth) -> auth .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() ); 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/publicdata/dto/PublicServiceConditionsDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java index 9e7a019..f6f0d2a 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 @@ -29,7 +29,7 @@ public class PublicServiceConditionsDataDto { @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("JA0101") private String targetGenderMale; 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..7fb8317 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 @@ -30,7 +30,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/service/PublicServiceDataServiceImpl.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java index f6d8315..d10a356 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 @@ -36,7 +36,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 +145,7 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { /** * 공통 데이터 동기화 메서드 - 중복 코드 제거를 위한 템플릿 메서드 */ - private long syncDataWithPaging( + private void syncDataWithPaging( PublicDataPath apiPath, BiFunction> fetcher, // 데이터 조회 함수 Function> dataExtractor, // DTO에서 데이터 추출 함수 @@ -204,7 +204,6 @@ private long syncDataWithPaging( } log.info("{} 전체 동기화 완료: 총 {}건", operationName, totalProcessed); - return totalProcessed; } /** @@ -260,12 +259,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)); @@ -312,12 +311,12 @@ public List upsertSupportConditionsData(Lis 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)); 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..f91e02d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java @@ -56,7 +56,7 @@ public ResponseEntity> getServicesByCategory // 공공서비스 상세 조회 @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/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/entity/PublicService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java index 5e8057f..6038465 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -16,7 +16,7 @@ @AllArgsConstructor public class PublicService { @Id - private Long id; + private String id; @Column(name = "service_name", nullable = false, length = 255) private String serviceName; // 서비스명 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/repository/PublicServiceRepository.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java index 54c4701..c2128b0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -16,14 +16,14 @@ import java.util.Optional; @Repository -public interface PublicServiceRepository extends JpaRepository { +public interface PublicServiceRepository extends JpaRepository { Page findByServiceCategory(ServiceCategory category, Pageable pageable); 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 " + 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..ec63664 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -50,7 +50,7 @@ public Page getServicesByCategory(ServiceCategory // 서비스 상세 조회 @Transactional - public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long userId) { + public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long userId) { PublicService service = publicServiceValidate.validateServiceById(serviceId); // 조회수 증가 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..3b92454 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)); } From 903b4e6105b28c574ce9a8649318aa50fe69842f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 17 Apr 2025 02:48:06 +0900 Subject: [PATCH 02/41] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=88=98=EC=A0=95,=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/common/config/SecurityConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..2bbb346 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -50,7 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((auth) -> auth .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 +72,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")); From 0c537a42c24694da35be9d84529482a7d6d72afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 17 Apr 2025 17:54:04 +0900 Subject: [PATCH 03/41] =?UTF-8?q?fix:=20post=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20id=20Long=20->=20String=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/post/dto/PostDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/entity/Post.java | 2 +- .../hyetaekon/hyetaekon/post/service/PostService.java | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java index 61cea56..34b975b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java @@ -10,7 +10,7 @@ public class PostDto { private Long id; private Long userId; - private Long publicServiceId; + private String publicServiceId; private String title; private String content; private LocalDateTime createdAt; 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..f64acdd 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -11,7 +11,7 @@ @Entity @Getter @Setter -@Table(name = "posts") +@Table(name = "post") public class Post { @Id 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..614f49c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -47,13 +47,18 @@ public PostDto createPost(PostDto postDto) { public PostDto updatePost(Long id, PostDto postDto) { Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + User user = new User(); user.setId(postDto.getUserId()); post.setUser(user); - PublicService publicService = new PublicService(); - publicService.setId(postDto.getPublicServiceId()); - post.setPublicService(publicService); + if (postDto.getPublicServiceId() != null) { + PublicService publicService = new PublicService(); + publicService.setId(postDto.getPublicServiceId()); // String 타입으로 변환됨 + post.setPublicService(publicService); + } else { + post.setPublicService(null); + } post.setTitle(postDto.getTitle()); post.setContent(postDto.getContent()); From 4b9e0fa36af11bc19672cbbc4eaf922ef50f036a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 06:33:50 +0900 Subject: [PATCH 04/41] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=A4=ED=95=91=20=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PublicServiceDataController.java | 26 ++++++++--- .../dto/PublicServiceConditionsDataDto.java | 41 +++++++++++++++-- .../mapper/PublicServiceDataMapper.java | 2 + .../schedule/PublicServiceDataScheduler.java | 4 +- .../PublicServiceDataProviderService.java | 7 +++ .../service/PublicServiceDataServiceImpl.java | 6 ++- .../publicdata/util/PublicDataPath.java | 4 +- .../util/PublicServiceDataValidate.java | 19 ++++---- .../controller/PublicServiceController.java | 7 ++- .../publicservice/entity/PublicService.java | 46 ++++++++++--------- 10 files changed, 110 insertions(+), 52 deletions(-) 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 f6f0d2a..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,6 +20,7 @@ public class PublicServiceConditionsDataDto { private long perPage; @Getter + @Setter @NoArgsConstructor @AllArgsConstructor @Builder @@ -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/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/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 d10a356..39881d7 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 @@ -8,11 +8,13 @@ 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; @@ -305,7 +307,7 @@ public List upsertServiceDetailData(List upsertSupportConditionsData(List dataList) { List validatedData = new ArrayList<>(); List entitiesToSave = new ArrayList<>(); 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..6815150 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 @@ -75,28 +75,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 +104,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 +113,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/publicservice/controller/PublicServiceController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java index f91e02d..cc35578 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java @@ -2,7 +2,6 @@ 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; @@ -34,7 +33,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(); @@ -43,7 +42,7 @@ public ResponseEntity> getAllServices( } // 서비스 분야별 공공서비스 목록 조회 - @GetMapping("/category/{category}") + /*@GetMapping("/category/{category}") public ResponseEntity> getServicesByCategory ( @PathVariable("category") String categoryName, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, @@ -52,7 +51,7 @@ public ResponseEntity> getServicesByCategory ServiceCategory category = publicServiceHandler.getServiceCategory(categoryName); return ResponseEntity.ok(publicServiceHandler.getServicesByCategory(category, PageRequest.of(page, size), userId)); - } + }*/ // 공공서비스 상세 조회 @GetMapping("/detail/{serviceId}") 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 6038465..b4f4a44 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -29,62 +29,62 @@ public class PublicService { @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<>(); From 39eb0d70dac416f1eae8fcde2a713221d502f480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 06:34:32 +0900 Subject: [PATCH 05/41] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=B0=A9=EC=8B=9D=EA=B3=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B6=84=EC=95=BC=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/PublicServiceDetailDataDto.java | 5 +---- .../mapper/PublicServiceMapper.java | 1 + .../service/PublicServiceHandler.java | 21 +++++++++---------- .../util/PublicServiceValidate.java | 8 ++++++- 4 files changed, 19 insertions(+), 16 deletions(-) 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 7fb8317..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; 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..f116a50 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,7 @@ @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PublicServiceMapper { @Mapping(source = "id", target = "publicServiceId") + @Mapping(target = "serviceCategory", expression = "java(publicService.getServiceCategory().getType())") PublicServiceListResponseDto toListDto(PublicService publicService); @Mapping(source = "id", target = "publicServiceId") 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 ec63664..1d1c76f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -35,7 +35,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,7 +46,7 @@ public Page getServicesByCategory(ServiceCategory } return dto; }); - } + }*/ // 서비스 상세 조회 @Transactional @@ -99,18 +99,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 +121,7 @@ public Page getAllServices( PageRequest pageRequest = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), - Sort.by(direction, sortField) + sorts ); // 필터링 조건에 따른 서비스 조회 @@ -184,7 +183,7 @@ public Page getAllServices( }); } - public Page getBookmarkedServices(Long userId, Pageable pageable) { + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); List serviceDtos = bookmarkedServices.getContent().stream() @@ -197,5 +196,5 @@ public Page getBookmarkedServices(Long userId, Pag return new PageImpl<>(serviceDtos, pageable, bookmarkedServices.getTotalElements()); - } + } } 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 3b92454..22c0a6b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java @@ -28,10 +28,16 @@ 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); } } From e4ee87ee0c7f37548089a77aa79a10e747ac978a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 15:30:05 +0900 Subject: [PATCH 06/41] =?UTF-8?q?refactor:=20post=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/post/entity/Post.java | 32 ++++++++++++++----- .../hyetaekon/post/entity/PostImage.java | 6 ++-- .../hyetaekon/post/entity/PostType.java | 15 +++++++-- .../publicservice/entity/PublicService.java | 2 +- 4 files changed, 41 insertions(+), 14 deletions(-) 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 f64acdd..2418f11 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -3,14 +3,18 @@ import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; 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 +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "post") public class Post { @@ -26,10 +30,10 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + @Column(length = 50, nullable = false) // ✅ 제목 25자 제한 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(); @@ -38,15 +42,18 @@ public class Post { private int recommendCnt; // 추천수 - private int viewCount; // 조회수 + private int viewCnt; // 조회수 + + // TODO: 댓글 생성/수정 시 업데이트 + private int commentCnt; // 댓글수 - @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(length = 100) private String urlTitle; private String urlPath; @@ -57,6 +64,15 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private List postImages; // ✅ 게시글 이미지와 연결 + private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + + 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..706f171 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,14 @@ package com.hyetaekon.hyetaekon.post.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class PostImage { @Id 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..f777c54 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,16 @@ 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 // 인사 게시판 + QUESTION("질문"), + FREE("자유"), + GREETING("인사"); + + @JsonValue + private final String koreanName; } 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 b4f4a44..6bdbb99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -23,7 +23,7 @@ public class PublicService { // 서비스 분야 - 카테고리 + 해시태그 @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "public_category", nullable = false) private ServiceCategory serviceCategory; // 서비스 분야 @Column(name = "summary_purpose", columnDefinition = "TEXT") From bcc84f4af78dd2885b8cac7a10283b317ab72741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 16:24:20 +0900 Subject: [PATCH 07/41] =?UTF-8?q?feat:=20post=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20postType=EB=B3=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9E=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 45 ++++++++++++------- .../post/dto/PostDetailReponseDto.java | 26 +++++++++++ .../post/dto/PostListResponseDto.java | 20 +++++++++ .../post/repository/PostRepository.java | 2 +- .../hyetaekon/post/service/PostService.java | 2 +- 5 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java 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..fbd30d7 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.PostDetailReponseDto; import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.PostListResponseDto; +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.web.bind.annotation.*; -import java.util.List; +@Slf4j @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -15,28 +22,34 @@ public class PostController { private final PostService postService; - // ✅ 게시글 생성 - @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); + // 전체 게시글 목록 조회 + @GetMapping + public ResponseEntity> getAllPosts( + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page posts = postService.getAllPosts(pageable); + return ResponseEntity.ok(posts); + } + + // PostType에 해당하는 게시글 목록 조회 + @GetMapping("/type/{postType}") + public ResponseEntity> getPostsByType( + @PathVariable PostType postType, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page posts = postService.getPostsByType(postType, pageable); + return ResponseEntity.ok(posts); } // ✅ 특정 게시글 조회 @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { + public ResponseEntity getPost(@PathVariable Long postId) { return ResponseEntity.ok(postService.getPostById(postId)); } - // ✅ 특정 카테고리의 게시글 조회 - @GetMapping("/category/{categoryId}") - public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { - return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); - } - - // ✅ 모든 게시글 조회 - @GetMapping - public ResponseEntity> getAllPosts() { - return ResponseEntity.ok(postService.getAllPosts()); + // User, Admin에 따라 다른 접근 가능 + // ✅ 게시글 생성 + @PostMapping + public ResponseEntity createPost(@RequestBody PostDto postDto) { + return ResponseEntity.ok(postService.createPost(postDto)); } // ✅ 게시글 수정 diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java new file mode 100644 index 0000000..7007910 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java @@ -0,0 +1,26 @@ +package com.hyetaekon.hyetaekon.post.dto; + + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDetailReponseDto { + private Long postId; + private Long nickName; // 작성자 닉네임 + private String title; + private String content; + private LocalDateTime createdAt; + private int recommendCnt; + private int viewCnt; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 추가 +} 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..8484c26 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -0,0 +1,20 @@ +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 Long nickName; // 작성자 닉네임 + private String content; + private LocalDateTime createdAt; + private int viewCnt; + private String postType; +} 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..0a72be9 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -8,7 +8,7 @@ @Repository public interface PostRepository extends JpaRepository { - List findByCategoryId(Long categoryId); // 추가됨 + List findByCategoryId(Long categoryId); boolean existsByUser_IdAndDeletedAtIsNull(Long userId); } 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 614f49c..0e30e9c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -66,7 +66,7 @@ public PostDto updatePost(Long id, PostDto postDto) { post.setDeletedAt(postDto.getDeletedAt()); post.setServiceUrl(postDto.getServiceUrl()); post.setRecommendCnt(postDto.getRecommendCnt()); - post.setViewCount(postDto.getViewCount()); + post.setViewCnt(postDto.getViewCnt()); post.setUrlTitle(postDto.getUrlTitle()); post.setUrlPath(postDto.getUrlPath()); post.setTags(postDto.getTags()); From 03fbcf8b131c33c77a1ace5ee3f2887a83e658c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:50:12 +0900 Subject: [PATCH 08/41] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/exception/ErrorCode.java | 5 ++- .../hyetaekon/hyetaekon/post/entity/Post.java | 37 +++++++++++++--- .../controller/RecommendController.java | 28 +++++++++++- .../recommend/service/RecommendService.java | 43 +++++++++++++++++++ .../user/controller/UserController.java | 18 ++------ 5 files changed, 108 insertions(+), 23 deletions(-) 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..52b2349 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -40,7 +40,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개까지만 등록 가능합니다."), 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 2418f11..6ad232d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -1,6 +1,8 @@ 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.*; @@ -30,7 +32,7 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 50, nullable = false) // ✅ 제목 25자 제한 + @Column(columnDefinition = "VARCHAR(20) CHARACTER SET utf8mb4", nullable = false) // ✅ 제목 20자 제한 private String title; @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 @@ -40,12 +42,18 @@ public class Post { private LocalDateTime deletedAt; - private int recommendCnt; // 추천수 + @Builder.Default + @Column(name = "recommend_cnt") + private int recommendCnt = 0; // 추천수 - private int viewCnt; // 조회수 + @Builder.Default + @Column(name = "view_cnt") + private int viewCnt = 0; // 조회수 // TODO: 댓글 생성/수정 시 업데이트 - private int commentCnt; // 댓글수 + @Builder.Default + @Column(name = "comment_cnt") + private int commentCnt = 0; // 댓글수 @Column(name = "post_type", nullable = false) @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) @@ -53,7 +61,7 @@ public class Post { private String serviceUrl; - @Column(length = 100) + @Column(columnDefinition = "VARCHAR(12) CHARACTER SET utf8mb4") // ✅ url제목 12자 제한 private String urlTitle; private String urlPath; @@ -68,6 +76,25 @@ public class Post { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List recommends = new ArrayList<>(); + + // 조회수 증가 + 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++; } 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..be49b5d 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,21 @@ package com.hyetaekon.hyetaekon.recommend.service; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +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.publicservice.entity.PublicService; +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.*; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.BOOKMARK_NOT_FOUND; + @Service @Transactional @RequiredArgsConstructor @@ -15,4 +24,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/user/controller/UserController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java index 6546a5d..0bd9662 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -105,24 +105,12 @@ public ResponseEntity> getBookmarkedServices( // @GetMapping("/me/posts") // @PreAuthorize("hasRole('USER')") // public ResponseEntity>> getMyPosts( -// @RequestParam(required = false) String postType, -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { +// @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + // @RequestParam(name = "size", defaultValue = "5") @Positive @Max(30) int size, + // @AuthenticationPrincipal CustomUserDetails userDetails) { // 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)); -// } } From 5506338ced204bb17992117a0859aeff8dc70e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:51:09 +0900 Subject: [PATCH 09/41] =?UTF-8?q?style:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/post/dto/PostDto.java | 27 ------------------- .../recommend/service/RecommendService.java | 3 --- 2 files changed, 30 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java deleted file mode 100644 index 34b975b..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.hyetaekon.hyetaekon.post.dto; - -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@Setter -public class PostDto { - private Long id; - private Long userId; - private String publicServiceId; - 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 String urlTitle; - private String urlPath; - private String tags; - private Long categoryId; // 추가됨 - private List imageUrls; // ✅ 이미지 URL 리스트 추가 -} 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 be49b5d..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,10 +1,8 @@ package com.hyetaekon.hyetaekon.recommend.service; -import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; 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.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; @@ -14,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional; import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; -import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.BOOKMARK_NOT_FOUND; @Service @Transactional From 07c495babefdcae3419f52832f8f5810550079f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:59:13 +0900 Subject: [PATCH 10/41] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4,=20type=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=95=20+=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8,=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20Dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 38 ++-- .../post/dto/PostCreateRequestDto.java | 23 ++ .../post/dto/PostDetailReponseDto.java | 2 + .../post/dto/PostUpdateRequestDto.java | 23 ++ .../hyetaekon/post/mapper/PostMapper.java | 71 +++---- .../post/repository/PostRepository.java | 13 +- .../hyetaekon/post/service/PostService.java | 199 ++++++++++++++---- 7 files changed, 270 insertions(+), 99 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java 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 fbd30d7..0dbcc90 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -1,8 +1,7 @@ package com.hyetaekon.hyetaekon.post.controller; -import com.hyetaekon.hyetaekon.post.dto.PostDetailReponseDto; -import com.hyetaekon.hyetaekon.post.dto.PostDto; -import com.hyetaekon.hyetaekon.post.dto.PostListResponseDto; +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; @@ -11,6 +10,7 @@ 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.*; @@ -39,29 +39,37 @@ public ResponseEntity> getPostsByType( return ResponseEntity.ok(posts); } - // ✅ 특정 게시글 조회 + // User, Admin에 따라 다른 접근 가능 + // ✅ 특정 게시글 상세 조회 @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { - return ResponseEntity.ok(postService.getPostById(postId)); + public ResponseEntity getPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); } - // User, Admin에 따라 다른 접근 가능 // ✅ 게시글 생성 @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); + public ResponseEntity createPost( + @RequestBody 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, + @RequestBody 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/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java new file mode 100644 index 0000000..2e7f5b4 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostCreateRequestDto { + private Long nickName; + private String title; + private String content; + private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java index 7007910..7c812e5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java @@ -17,10 +17,12 @@ public class PostDetailReponseDto { private String title; private String content; private LocalDateTime createdAt; + private String postType; private int recommendCnt; private int viewCnt; private String urlTitle; private String urlPath; private String tags; private List imageUrls; // ✅ 이미지 URL 리스트 추가 + private boolean recommended; // 현재 로그인한 사용자의 추천 여부 } 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..97a1eb2 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostUpdateRequestDto { + private Long nickName; + private String title; + private String content; + // private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 +} 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..936000f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -1,58 +1,51 @@ 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.List; 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); + + // Post -> PostDetailResponseDto 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + @Mapping(target = "imageUrls", expression = "java(mapImageUrls(post.getPostImages()))") + PostDetailReponseDto toPostDetailDto(Post post); + + // PostCreateRequestDto -> Post 변환 (새 게시글 생성) + @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") + Post toEntity(PostCreateRequestDto createDto); + + // 이미지 URL 목록 매핑을 위한 기본 메서드 + default List mapImageUrls(List postImages) { + if (postImages == null) { + return java.util.Collections.emptyList(); } - - return post; + return postImages.stream() + .map(com.hyetaekon.hyetaekon.post.entity.PostImage::getImageUrl) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toList()); } + // PostUpdateRequestDto로 Post 업데이트 + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); + default List mapPostImages(Post post) { if (post.getPostImages() == null) return null; return post.getPostImages().stream() 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 0a72be9..e8cc50d 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,25 @@ 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); } 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 0e30e9c..bc01780 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,81 +1,192 @@ package com.hyetaekon.hyetaekon.post.service; -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.post.mapper.PostMapper; 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.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.persistence.EntityNotFoundException; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; + private final UserRepository userRepository; + private final RecommendRepository recommendRepository; private final PostMapper postMapper; - public List getAllPosts() { - return postRepository.findAll() - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 전체 게시글 목록 조회 (페이징) + */ + public Page getAllPosts(Pageable pageable) { + return postRepository.findByDeletedAtIsNull(pageable) + .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, Pageable pageable) { + return postRepository.findByPostTypeAndDeletedAtIsNull(postType, pageable) + .map(postMapper::toPostListDto); } - public List getPostsByCategoryId(Long categoryId) { // 추가됨 - return postRepository.findByCategoryId(categoryId) - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 특정 게시글 상세 조회(로그인 시) + */ + @Transactional + public PostDetailReponseDto 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 변환 및 추천 여부 설정 + PostDetailReponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); // DTO에 isRecommended 필드 추가 필요 + + return postMapper.toPostDetailDto(post); } - public PostDto createPost(PostDto postDto) { - Post post = postMapper.toEntity(postDto); + /** + * 게시글 생성(로그인 시) + */ + @Transactional + public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + // PostType Enum 변환 + PostType postType = findPostTypeByName(requestDto.getPostType()); + + // DTO -> Entity 변환 + Post post = postMapper.toEntity(requestDto); + post.setUser(user); + post.setPostType(postType); + + // 이미지 URL 처리 + if (requestDto.getImageUrls() != null && !requestDto.getImageUrls().isEmpty()) { + List postImages = new ArrayList<>(); + + for (String imageUrl : requestDto.getImageUrls()) { + PostImage postImage = PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .build(); + postImages.add(postImage); + } + + post.setPostImages(postImages); + } + Post savedPost = postRepository.save(post); - return postMapper.toDto(savedPost); + return postMapper.toPostDetailDto(savedPost); } - public PostDto updatePost(Long id, PostDto postDto) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + /** + * 게시글 수정 (본인만 가능) + */ + @Transactional + public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); - User user = new User(); - user.setId(postDto.getUserId()); - post.setUser(user); + // 작성자 확인 + if (!post.getUser().getId().equals(userId)) { + throw new AccessDeniedException("게시글 수정 권한이 없습니다"); + } - if (postDto.getPublicServiceId() != null) { - PublicService publicService = new PublicService(); - publicService.setId(postDto.getPublicServiceId()); // String 타입으로 변환됨 - post.setPublicService(publicService); - } else { - post.setPublicService(null); + // PostType 변환 + if (updateDto.getPostType() != null) { + PostType postType = findPostTypeByName(updateDto.getPostType()); + post.setPostType(postType); } - 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.setViewCnt(postDto.getViewCnt()); - 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); + // 기본 정보 업데이트 + postMapper.updatePostFromDto(updateDto, post); + + // 이미지 업데이트 처리 + if (updateDto.getImageUrls() != null) { + // 기존 이미지 제거 + post.getPostImages().clear(); + + // 새 이미지 추가 + List postImages = updateDto.getImageUrls().stream() + .map(imageUrl -> PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .build()) + .collect(Collectors.toList()); + + post.getPostImages().addAll(postImages); + } + + 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()); } - public void deletePost(Long id) { - postRepository.deleteById(id); + /** + * 게시글 타입명으로 Enum 조회 + */ + private PostType findPostTypeByName(String postTypeName) { + try { + // 한글명으로 찾기 + for (PostType type : PostType.values()) { + if (type.getKoreanName().equals(postTypeName)) { + return type; + } + } + + // Enum 상수명으로 찾기 + return PostType.valueOf(postTypeName); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); + } } + + } From cb6d65382c035ac481d00961630dd686bae1071a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 26 Apr 2025 17:37:51 +0900 Subject: [PATCH 11/41] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1,=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8,?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EB=B0=A9=EC=8B=9D=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20soft=20Delete=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EB=8F=84=EC=9E=85,=20request=20=EC=8B=9C=EC=97=90=20url?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 6 +- .../post/dto/PostCreateRequestDto.java | 3 +- ...nseDto.java => PostDetailResponseDto.java} | 2 +- .../post/dto/PostUpdateRequestDto.java | 3 +- .../hyetaekon/post/entity/PostImage.java | 14 ++ .../post/mapper/PostImageMapper.java | 38 +++++ .../hyetaekon/post/mapper/PostMapper.java | 42 +++--- .../post/repository/PostImageRepository.java | 13 ++ .../hyetaekon/post/service/PostService.java | 139 +++++++++++++----- 9 files changed, 193 insertions(+), 67 deletions(-) rename src/main/java/com/hyetaekon/hyetaekon/post/dto/{PostDetailReponseDto.java => PostDetailResponseDto.java} (94%) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java 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 0dbcc90..c842b0b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -42,7 +42,7 @@ public ResponseEntity> getPostsByType( // User, Admin에 따라 다른 접근 가능 // ✅ 특정 게시글 상세 조회 @GetMapping("/{postId}") - public ResponseEntity getPost( + public ResponseEntity getPost( @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); @@ -50,7 +50,7 @@ public ResponseEntity getPost( // ✅ 게시글 생성 @PostMapping - public ResponseEntity createPost( + public ResponseEntity createPost( @RequestBody PostCreateRequestDto requestDto, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.createPost(requestDto, userDetails.getId())); @@ -58,7 +58,7 @@ public ResponseEntity createPost( // ✅ 게시글 수정 - 본인 @PutMapping("/{postId}") - public ResponseEntity updatePost( + public ResponseEntity updatePost( @PathVariable Long postId, @RequestBody PostUpdateRequestDto updateDto, @AuthenticationPrincipal CustomUserDetails userDetails) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java index 2e7f5b4..e542bd5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.post.dto; import lombok.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -19,5 +20,5 @@ public class PostCreateRequestDto { private String urlTitle; private String urlPath; private String tags; - private List imageUrls; // ✅ 이미지 URL 리스트 + private List images; // 이미지 파일 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java similarity index 94% rename from src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java rename to src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index 7c812e5..a4ccc5a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -11,7 +11,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class PostDetailReponseDto { +public class PostDetailResponseDto { private Long postId; private Long nickName; // 작성자 닉네임 private String title; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java index 97a1eb2..8d6419c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.post.dto; import lombok.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -19,5 +20,5 @@ public class PostUpdateRequestDto { private String urlTitle; private String urlPath; private String tags; - private List imageUrls; // ✅ 이미지 URL 리스트 + private List images; // 이미지 파일 } 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 706f171..7325e94 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -21,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/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 936000f..6a82fd1 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -3,15 +3,16 @@ 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.*; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +@Mapper(componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {PostImageMapper.class}) public interface PostMapper { // Post -> PostListResponseDto 변환 @@ -24,32 +25,27 @@ public interface PostMapper { @Mapping(source = "id", target = "postId") @Mapping(source = "user.nickname", target = "nickName") @Mapping(source = "postType.koreanName", target = "postType") - @Mapping(target = "imageUrls", expression = "java(mapImageUrls(post.getPostImages()))") - PostDetailReponseDto toPostDetailDto(Post post); + @Mapping(target = "imageUrls", source = "postImages", qualifiedByName = "mapPostImagesToUrls") + PostDetailResponseDto toPostDetailDto(Post post); // PostCreateRequestDto -> Post 변환 (새 게시글 생성) @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") Post toEntity(PostCreateRequestDto createDto); - // 이미지 URL 목록 매핑을 위한 기본 메서드 - default List mapImageUrls(List postImages) { - if (postImages == null) { - return java.util.Collections.emptyList(); - } - return postImages.stream() - .map(com.hyetaekon.hyetaekon.post.entity.PostImage::getImageUrl) - .filter(java.util.Objects::nonNull) - .collect(java.util.stream.Collectors.toList()); - } - - // PostUpdateRequestDto로 Post 업데이트 + // null 아닌 값만 업데이트 @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); - default List mapPostImages(Post post) { - if (post.getPostImages() == null) return null; - return post.getPostImages().stream() - .map(PostImage::getImageUrl) - .collect(Collectors.toList()); + // 이미지만 URL 리스트로 변환 (soft delete 처리된 것 제외) + @Named("mapPostImagesToUrls") + default List mapPostImagesToUrls(List postImages) { + if (postImages == null) { + return Collections.emptyList(); + } + 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/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index bc01780..143e44e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,10 +1,14 @@ package com.hyetaekon.hyetaekon.post.service; +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.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; @@ -18,10 +22,14 @@ 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 @@ -29,9 +37,21 @@ 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" + ); /** * 전체 게시글 목록 조회 (페이징) @@ -53,7 +73,7 @@ public Page getPostsByType(PostType postType, Pageable page * 특정 게시글 상세 조회(로그인 시) */ @Transactional - public PostDetailReponseDto getPostById(Long postId, Long userId) { + public PostDetailResponseDto getPostById(Long postId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); @@ -67,17 +87,17 @@ public PostDetailReponseDto getPostById(Long postId, Long userId) { boolean recommended = recommendRepository.existsByUserIdAndPostId(userId, postId); // DTO 변환 및 추천 여부 설정 - PostDetailReponseDto responseDto = postMapper.toPostDetailDto(post); - responseDto.setRecommended(recommended); // DTO에 isRecommended 필드 추가 필요 + PostDetailResponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); - return postMapper.toPostDetailDto(post); + return responseDto; } /** * 게시글 생성(로그인 시) */ @Transactional - public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + public PostDetailResponseDto createPost(PostCreateRequestDto requestDto, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); @@ -89,22 +109,17 @@ public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long use post.setUser(user); post.setPostType(postType); - // 이미지 URL 처리 - if (requestDto.getImageUrls() != null && !requestDto.getImageUrls().isEmpty()) { - List postImages = new ArrayList<>(); + // 게시글 저장 + Post savedPost = postRepository.save(post); - for (String imageUrl : requestDto.getImageUrls()) { - PostImage postImage = PostImage.builder() - .post(post) - .imageUrl(imageUrl) - .build(); - postImages.add(postImage); + // 이미지 처리 + if (requestDto.getImages() != null && !requestDto.getImages().isEmpty()) { + List postImages = processPostImages(requestDto.getImages(), savedPost); + if (!postImages.isEmpty()) { + postImageRepository.saveAll(postImages); } - - post.setPostImages(postImages); } - Post savedPost = postRepository.save(post); return postMapper.toPostDetailDto(savedPost); } @@ -112,7 +127,7 @@ public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long use * 게시글 수정 (본인만 가능) */ @Transactional - public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { Post post = postRepository.findByIdAndDeletedAtIsNull(postId) .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); @@ -131,19 +146,18 @@ public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateD postMapper.updatePostFromDto(updateDto, post); // 이미지 업데이트 처리 - if (updateDto.getImageUrls() != null) { - // 기존 이미지 제거 - post.getPostImages().clear(); + if (updateDto.getImages() != null && !updateDto.getImages().isEmpty()) { + // 기존 이미지 soft delete 처리 + List existingImages = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : existingImages) { + image.softDelete(); + } // 새 이미지 추가 - List postImages = updateDto.getImageUrls().stream() - .map(imageUrl -> PostImage.builder() - .post(post) - .imageUrl(imageUrl) - .build()) - .collect(Collectors.toList()); - - post.getPostImages().addAll(postImages); + List newImages = processPostImages(updateDto.getImages(), post); + if (!newImages.isEmpty()) { + postImageRepository.saveAll(newImages); + } } return postMapper.toPostDetailDto(post); @@ -167,26 +181,75 @@ public void deletePost(Long postId, Long userId, String role) { // 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); + } + } } /** * 게시글 타입명으로 Enum 조회 */ private PostType findPostTypeByName(String postTypeName) { - try { - // 한글명으로 찾기 - for (PostType type : PostType.values()) { - if (type.getKoreanName().equals(postTypeName)) { - return type; - } + // 한글명으로 찾기 + for (PostType type : PostType.values()) { + if (type.getKoreanName().equals(postTypeName)) { + return type; } + } - // Enum 상수명으로 찾기 + try { + // 한글명으로 찾지 못한 경우 Enum 상수명으로 시도 return PostType.valueOf(postTypeName); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); } } - } From 5495469d0aae7d4f715add7c40f51f38dd29b63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 27 Apr 2025 02:14:31 +0900 Subject: [PATCH 12/41] =?UTF-8?q?feat:=20=EC=9E=90=EC=8B=A0=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EC=B6=94=EC=B2=9C?= =?UTF-8?q?=ED=95=9C=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/dto/MyPostListResponseDto.java | 23 +++++++++ .../post/dto/PostListResponseDto.java | 1 - .../hyetaekon/post/mapper/PostMapper.java | 4 ++ .../post/repository/PostRepository.java | 6 +++ .../hyetaekon/post/service/PostService.java | 15 ++++++ .../service/PublicServiceHandler.java | 30 ++++++++---- .../user/controller/UserController.java | 48 +++++++++++++------ 7 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java 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..0767fbf --- /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 Long nickName; // 작성자 닉네임 + private LocalDateTime createdAt; + private int recommendCnt; + private int commentCnt; + private int viewCnt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java index 8484c26..d0b4c98 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -13,7 +13,6 @@ public class PostListResponseDto { private Long postId; private String title; private Long nickName; // 작성자 닉네임 - private String content; private LocalDateTime createdAt; private int viewCnt; private String postType; 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 6a82fd1..fbeb91a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -21,6 +21,10 @@ public interface PostMapper { @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") 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 e8cc50d..30057b0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -22,4 +22,10 @@ public interface PostRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByUser_IdAndDeletedAtIsNull(Long userId); + + // 특정 사용자가 작성한 게시글 조회 + Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + + // 특정 사용자가 추천한 게시글 조회 + Page findByRecommendsUserIdAndDeletedAtIsNull(Long userId, 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 143e44e..e78f3d5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -252,4 +252,19 @@ private PostType findPostTypeByName(String postTypeName) { } } + /** + * 사용자가 작성한 게시글 목록 조회 + */ + public Page getPostsByUserId(Long userId, Pageable pageable) { + return postRepository.findByUserIdAndDeletedAtIsNull(userId, pageable) + .map(postMapper::toMyPostListDto); + } + + /** + * 사용자가 추천한 게시글 목록 조회 + */ + 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/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 1d1c76f..441261b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -77,7 +77,7 @@ public List getPopularServices(Long userId) { .map(service -> { PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); // 로그인한 사용자는 북마크 여부 확인 - if (userId == 0L) { + if (userId != 0L) { dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); } return dto; @@ -141,10 +141,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); } } @@ -153,10 +159,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); } } 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 0bd9662..18b09c0 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") @@ -89,28 +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(name = "page", defaultValue = "0") @Min(0) int page, - // @RequestParam(name = "size", defaultValue = "5") @Positive @Max(30) int size, - // @AuthenticationPrincipal CustomUserDetails userDetails) { -// Page posts = userService.getMyPosts(postType, PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(posts)); -// } + /** + * 내가 작성한 게시글 목록 조회 + */ + @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); + } } From 7e81c811c68aaf9e924ebdd56e705046d404a6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 27 Apr 2025 02:45:44 +0900 Subject: [PATCH 13/41] =?UTF-8?q?feat:=20post=20security=20path=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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..d101e4c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -13,19 +13,24 @@ public class SecurityPath { "/api/services", "/api/services/category/*", "/api/services/detail/*", - "/api/public-data/serviceList/test" + "/api/public-data/serviceList/test", + "/api/posts/type/*", + "/api/posts/search" }; // hasRole("USER") public static final String[] USER_ENDPOINTS = { - "/api/users/me/**", "/api/users/me", + "/api/users/me/**", + "/users/me/recommended/posts", "/api/logout", "/api/services/popular", "/api/services/*/bookmark", "/api/interests", - "/api/interests/me" + "/api/interests/me", + "/api/posts", + "/api/posts/*" }; // hasRole("ADMIN") From 034ceda18483bd47264ca7dbfdaa02f508df96bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 27 Apr 2025 03:24:26 +0900 Subject: [PATCH 14/41] =?UTF-8?q?feat:=20post=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Security=20Path=20endpoint=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84,=20post=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/common/config/SecurityConfig.java | 2 ++ .../hyetaekon/hyetaekon/common/config/SecurityPath.java | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) 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 2bbb346..0d10bf8 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; @@ -51,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests((auth) -> auth .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.GET, SecurityPath.PUBLIC_GET_ENDPOINTS).permitAll() .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() .anyRequest().permitAll() ); 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 d101e4c..8a1f191 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -12,9 +12,13 @@ public class SecurityPath { "/", "/api/services", "/api/services/category/*", - "/api/services/detail/*", + "/api/services/detail/*" + }; + + // GET 메소드에 대해 permitAll + public static final String[] PUBLIC_GET_ENDPOINTS = { "/api/public-data/serviceList/test", - "/api/posts/type/*", + "/api/posts", "/api/posts/search" }; From 168c045c68265c0559310d14e1b37e9cb063a62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 27 Apr 2025 03:24:42 +0900 Subject: [PATCH 15/41] =?UTF-8?q?feat:=20postType=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=EB=AA=85=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 22 +++++++++---------- .../hyetaekon/post/entity/PostType.java | 14 ++++++++++++ .../hyetaekon/post/service/PostService.java | 15 ++----------- 3 files changed, 26 insertions(+), 25 deletions(-) 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 c842b0b..d2310b4 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -22,21 +22,19 @@ public class PostController { private final PostService postService; - // 전체 게시글 목록 조회 + // PostType에 해당하는 게시글 목록 조회 @GetMapping - public ResponseEntity> getAllPosts( + public ResponseEntity> getPosts( + @RequestParam(required = false, defaultValue = "전체") String postType, @PageableDefault(page = 0, size = 10) Pageable pageable) { - Page posts = postService.getAllPosts(pageable); - return ResponseEntity.ok(posts); - } - // PostType에 해당하는 게시글 목록 조회 - @GetMapping("/type/{postType}") - public ResponseEntity> getPostsByType( - @PathVariable PostType postType, - @PageableDefault(page = 0, size = 10) Pageable pageable) { - Page posts = postService.getPostsByType(postType, pageable); - return ResponseEntity.ok(posts); + PostType type = PostType.fromKoreanName(postType); + + if (type == PostType.ALL) { + return ResponseEntity.ok(postService.getAllPosts(pageable)); + } else { + return ResponseEntity.ok(postService.getPostsByType(type, pageable)); + } } // User, Admin에 따라 다른 접근 가능 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 f777c54..c704720 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java @@ -7,10 +7,24 @@ @Getter @AllArgsConstructor public enum PostType { + 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/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index e78f3d5..0fd7e8b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -237,19 +237,8 @@ private void validateImages(List images) { * 게시글 타입명으로 Enum 조회 */ private PostType findPostTypeByName(String postTypeName) { - // 한글명으로 찾기 - for (PostType type : PostType.values()) { - if (type.getKoreanName().equals(postTypeName)) { - return type; - } - } - - try { - // 한글명으로 찾지 못한 경우 Enum 상수명으로 시도 - return PostType.valueOf(postTypeName); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); - } + // 한글명으로 PostType 찾기 + return PostType.fromKoreanName(postTypeName); } /** From 0c52355d470aaf98a78daf9a7d7128857632e620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 27 Apr 2025 03:30:58 +0900 Subject: [PATCH 16/41] =?UTF-8?q?feat:=20post=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C,=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=EC=A0=90=EC=97=90?= =?UTF-8?q?=20=ED=98=84=EC=9E=AC=20=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/post/entity/Post.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 6ad232d..ce3c8fa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -38,7 +38,8 @@ public class Post { @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; @@ -80,6 +81,14 @@ public class Post { @Builder.Default private List recommends = new ArrayList<>(); + // 저장 시점에 현재 시간으로 설정 + @PrePersist + public void prePersist() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } + // 조회수 증가 public void incrementViewCnt() { this.viewCnt++; @@ -102,4 +111,6 @@ public void incrementCommentCnt() { public void decrementCommentCnt() { this.commentCnt = Math.max(0, this.commentCnt - 1); } + + } From c606f323aca6457c453618150536e604144e13d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Mon, 28 Apr 2025 21:21:05 +0900 Subject: [PATCH 17/41] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B3=80=EA=B2=BD=20=ED=9B=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityConfig.java | 2 +- .../hyetaekon/common/config/SecurityPath.java | 14 +++----------- .../hyetaekon/post/controller/PostController.java | 2 +- .../hyetaekon/post/mapper/PostMapper.java | 6 +++--- .../hyetaekon/post/service/PostService.java | 12 ++---------- 5 files changed, 10 insertions(+), 26 deletions(-) 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 0d10bf8..719bb1e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -50,9 +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).hasAnyRole("USER", "ADMIN") - .requestMatchers(HttpMethod.GET, SecurityPath.PUBLIC_GET_ENDPOINTS).permitAll() .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() .anyRequest().permitAll() ); 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 8a1f191..325e229 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -11,25 +11,17 @@ public class SecurityPath { "/api/users/check-duplicate", "/", "/api/services", + "/api/services/popular", "/api/services/category/*", - "/api/services/detail/*" - }; - - // GET 메소드에 대해 permitAll - public static final String[] PUBLIC_GET_ENDPOINTS = { - "/api/public-data/serviceList/test", - "/api/posts", - "/api/posts/search" + "/api/services/detail/*", + "/api/public-data/serviceList/test" }; - // hasRole("USER") public static final String[] USER_ENDPOINTS = { "/api/users/me", "/api/users/me/**", - "/users/me/recommended/posts", "/api/logout", - "/api/services/popular", "/api/services/*/bookmark", "/api/interests", "/api/interests/me", 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 d2310b4..7e36aab 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -23,7 +23,7 @@ public class PostController { private final PostService postService; // PostType에 해당하는 게시글 목록 조회 - @GetMapping + @GetMapping("/type") public ResponseEntity> getPosts( @RequestParam(required = false, defaultValue = "전체") String postType, @PageableDefault(page = 0, size = 10) Pageable pageable) { 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 fbeb91a..e993c25 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -10,9 +10,7 @@ import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring", - unmappedTargetPolicy = ReportingPolicy.IGNORE, - uses = {PostImageMapper.class}) +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PostMapper { // Post -> PostListResponseDto 변환 @@ -34,10 +32,12 @@ public interface PostMapper { // 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 처리된 것 제외) 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 0fd7e8b..32fde9e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -102,7 +102,7 @@ public PostDetailResponseDto createPost(PostCreateRequestDto requestDto, Long us .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); // PostType Enum 변환 - PostType postType = findPostTypeByName(requestDto.getPostType()); + PostType postType = PostType.fromKoreanName(requestDto.getPostType()); // DTO -> Entity 변환 Post post = postMapper.toEntity(requestDto); @@ -138,7 +138,7 @@ public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto update // PostType 변환 if (updateDto.getPostType() != null) { - PostType postType = findPostTypeByName(updateDto.getPostType()); + PostType postType = PostType.fromKoreanName(updateDto.getPostType()); post.setPostType(postType); } @@ -233,14 +233,6 @@ private void validateImages(List images) { } } - /** - * 게시글 타입명으로 Enum 조회 - */ - private PostType findPostTypeByName(String postTypeName) { - // 한글명으로 PostType 찾기 - return PostType.fromKoreanName(postTypeName); - } - /** * 사용자가 작성한 게시글 목록 조회 */ From 0d8190e670839323befd64b3f3a59691d0d26afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Mon, 28 Apr 2025 21:23:16 +0900 Subject: [PATCH 18/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=B0=8F=20query=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/publicservice/mapper/PublicServiceMapper.java | 2 ++ .../publicservice/repository/PublicServiceRepository.java | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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 f116a50..cd6982c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java @@ -13,6 +13,8 @@ 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/repository/PublicServiceRepository.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java index c2128b0..ebe778f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -29,8 +29,8 @@ public interface PublicServiceRepository extends JpaRepository findWithFilters( @Param("categories") List categories, @Param("specialGroupEnums") List specialGroupEnums, From 590efee12e70909af6efe5549fba3c1c8b56f15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Tue, 29 Apr 2025 11:04:40 +0900 Subject: [PATCH 19/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=9D=BC=EC=A0=95=20null=EA=B0=92=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/exception/ErrorCode.java | 3 ++- .../util/PublicServiceDataValidate.java | 19 ++++++++++------ .../service/PublicServiceHandler.java | 5 +++++ .../util/PublicServiceValidate.java | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 8 deletions(-) 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 52b2349..824cab0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -51,7 +51,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/util/PublicServiceDataValidate.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java index 6815150..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; } 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 441261b..9f8d892 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -53,6 +53,11 @@ public class PublicServiceHandler { 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); 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 22c0a6b..daae86f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java @@ -40,4 +40,26 @@ public ServiceCategory validateServiceCategory(String categoryName) { } 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; + } } From e2d8dea443adf9fc56d9edc96f5d9c9c5feba3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Tue, 29 Apr 2025 11:05:12 +0900 Subject: [PATCH 20/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20mongodb?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +- .../hyetaekon/HyetaekonApplication.java | 2 + .../mongodb/document/PublicData.java | 27 +++++++ .../repository/PublicDataMongoRepository.java | 13 ++++ .../service/PublicDataMongoService.java | 75 +++++++++++++++++++ src/main/resources/application.yml | 6 +- 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java diff --git a/build.gradle b/build.gradle index d19b993..baf0348 100644 --- a/build.gradle +++ b/build.gradle @@ -66,9 +66,9 @@ 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' // 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/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..c5f1302 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java @@ -0,0 +1,27 @@ +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; +} 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..ac8f87d --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java @@ -0,0 +1,13 @@ +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.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PublicDataMongoRepository extends MongoRepository { + Optional findByPublicServiceId(String publicServiceId); +} 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..c6c7223 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -0,0 +1,75 @@ +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicDataMongoService { + + private final PublicDataMongoRepository mongoRepository; + private final MongoTemplate mongoTemplate; + + public PublicData saveToMongo(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()); + + // MongoDB 문서 생성 + PublicData document = PublicData.builder() + .publicServiceId(publicService.getId()) + .serviceName(publicService.getServiceName()) + .summaryPurpose(publicService.getSummaryPurpose()) + .serviceCategory(publicService.getServiceCategory().getType()) + .specialGroup(specialGroups) + .familyType(familyTypes) + .build(); + + return mongoRepository.save(document); + } + + public List saveAllToMongo(List publicServices) { + List documents = publicServices.stream() + .map(this::convertToDocument) + .collect(Collectors.toList()); + + return mongoRepository.saveAll(documents); + } + + 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()); + + // MongoDB 문서 생성 + return PublicData.builder() + .publicServiceId(publicService.getId()) + .serviceName(publicService.getServiceName()) + .summaryPurpose(publicService.getSummaryPurpose()) + .serviceCategory(publicService.getServiceCategory().getType()) + .specialGroup(specialGroups) + .familyType(familyTypes) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 315d5b6..ea60cb6 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&appName=HyetaekOn + auto-index-creation: true redis: port: 6379 timeout: 2000 From d07e09cb554e26327f81e8903484d3fb07bf0b14 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 29 Apr 2025 12:00:58 +0900 Subject: [PATCH 21/41] =?UTF-8?q?refactor:=20mongodb=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EA=B3=B5=EA=B3=B5=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20mongodb=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mongodb/document/PublicData.java | 10 ++ .../service/PublicDataMongoService.java | 110 ++++++++++++++---- .../service/PublicServiceDataServiceImpl.java | 27 +++-- 3 files changed, 116 insertions(+), 31 deletions(-) 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 index c5f1302..6681011 100644 --- 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 @@ -24,4 +24,14 @@ public class PublicData { 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/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java index c6c7223..a03cf59 100644 --- 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 @@ -1,6 +1,5 @@ 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; @@ -10,6 +9,8 @@ import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; @Slf4j @@ -20,30 +21,17 @@ public class PublicDataMongoService { private final PublicDataMongoRepository mongoRepository; private final MongoTemplate mongoTemplate; + /** + * 단일 공공서비스 엔티티를 MongoDB에 저장 + */ public PublicData saveToMongo(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()); - - // MongoDB 문서 생성 - PublicData document = PublicData.builder() - .publicServiceId(publicService.getId()) - .serviceName(publicService.getServiceName()) - .summaryPurpose(publicService.getSummaryPurpose()) - .serviceCategory(publicService.getServiceCategory().getType()) - .specialGroup(specialGroups) - .familyType(familyTypes) - .build(); - + PublicData document = convertToDocument(publicService); return mongoRepository.save(document); } + /** + * 여러 공공서비스 엔티티를 MongoDB에 저장 + */ public List saveAllToMongo(List publicServices) { List documents = publicServices.stream() .map(this::convertToDocument) @@ -52,17 +40,31 @@ public List saveAllToMongo(List publicServices) { return mongoRepository.saveAll(documents); } + /** + * 공공서비스 엔티티를 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()); - // MongoDB 문서 생성 + // 직업 정보 추출 + 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()) @@ -70,6 +72,66 @@ private PublicData convertToDocument(PublicService publicService) { .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 List updateOrCreateBulkDocuments(List services) { + // 기존 ID 목록 가져오기 + List serviceIds = services.stream() + .map(PublicService::getId) + .collect(Collectors.toList()); + + // ID에 해당하는 문서 맵 생성 + Map existingDocsMap = mongoRepository.findAllById(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()); + + // 일괄 저장 + return mongoRepository.saveAll(docsToSave); + } +} \ No newline at end of file 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 39881d7..8f23761 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,6 +4,7 @@ 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.*; @@ -30,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; @@ -238,14 +239,18 @@ public List upsertServiceData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); } log.info("공공서비스 목록 데이터 {}건 저장 완료", validatedData.size()); @@ -290,14 +295,18 @@ public List upsertServiceDetailData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); } log.info("공공서비스 상세정보 데이터 {}건 저장 완료", validatedData.size()); @@ -346,14 +355,18 @@ public List upsertSupportConditionsData(Lis // 배치 처리 최적화: 1000개 단위로 저장 if (entitiesToSave.size() >= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + + publicDataMongoService.saveAllToMongo(savedEntities); } log.info("공공서비스 지원조건 데이터 {}건 저장 완료", validatedData.size()); From 86f4e3406997875369a58f5aa385cdea88326d39 Mon Sep 17 00:00:00 2001 From: uk0k Date: Tue, 29 Apr 2025 12:43:52 +0900 Subject: [PATCH 22/41] =?UTF-8?q?post=EC=A0=84=EC=B2=B4=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 11 +++-- .../post/repository/PostRepository.java | 7 ++++ .../hyetaekon/post/service/PostService.java | 40 +++++++++++++++---- 3 files changed, 46 insertions(+), 12 deletions(-) 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 d2310b4..ad62b5a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -25,15 +25,18 @@ public class PostController { // PostType에 해당하는 게시글 목록 조회 @GetMapping public ResponseEntity> getPosts( - @RequestParam(required = false, defaultValue = "전체") String postType, - @PageableDefault(page = 0, size = 10) Pageable pageable) { + @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) { PostType type = PostType.fromKoreanName(postType); if (type == PostType.ALL) { - return ResponseEntity.ok(postService.getAllPosts(pageable)); + return ResponseEntity.ok(postService.getAllPosts(keyword, sortBy, direction, pageable)); } else { - return ResponseEntity.ok(postService.getPostsByType(type, pageable)); + return ResponseEntity.ok(postService.getPostsByType(type, keyword, sortBy, direction, pageable)); } } 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 30057b0..502b593 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -28,4 +28,11 @@ public interface PostRepository extends JpaRepository { // 특정 사용자가 추천한 게시글 조회 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 0fd7e8b..89d7980 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -16,7 +16,9 @@ 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; @@ -54,19 +56,41 @@ public class PostService { ); /** - * 전체 게시글 목록 조회 (페이징) + * 전체 게시글 목록 조회 (제목 검색 + 정렬) */ - public Page getAllPosts(Pageable pageable) { - return postRepository.findByDeletedAtIsNull(pageable) - .map(postMapper::toPostListDto); + 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 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); + } } /** - * 특정 타입의 게시글 목록 조회 (페이징) + * 정렬 기준을 적용한 Pageable 생성 */ - public Page getPostsByType(PostType postType, Pageable pageable) { - return postRepository.findByPostTypeAndDeletedAtIsNull(postType, pageable) - .map(postMapper::toPostListDto); + 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); } /** From 1a7ff0fad49c1091717a96a56ecae55048534ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Tue, 29 Apr 2025 22:25:41 +0900 Subject: [PATCH 23/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PublicServiceController.java | 7 +++++ .../publicservice/dto/FilterOptionDto.java | 11 +++++++ .../service/PublicServiceHandler.java | 29 +++++++++++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java 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 cc35578..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,5 +1,6 @@ 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.service.PublicServiceHandler; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @Validated @@ -41,6 +43,11 @@ 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}") public ResponseEntity> getServicesByCategory ( 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/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 9f8d892..b11117b 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; @@ -21,8 +22,7 @@ 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 @@ -200,6 +200,31 @@ public Page getAllServices( }); } + // 기존 코드에 아래 메서드 추가 + 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; + } + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); From f35eb659b9189f393853cceb1146d618a8e833d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:53:14 +0900 Subject: [PATCH 24/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=ED=98=95=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5,?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../repository/UserInterestRepository.java | 18 + .../bookmark/service/BookmarkService.java | 8 + .../mongodb/SearchInfoController.java | 71 +++- ...ler.java => ServiceMatchedController.java} | 2 +- .../dto/mongodb/ServiceSearchCriteriaDto.java | 43 +++ .../dto/mongodb/ServiceSearchResultDto.java | 30 ++ .../entity/mongodb/ServiceInfo.java | 34 ++ .../mapper/mongodb/ServiceInfoMapper.java | 15 + .../mongodb/ServiceSearchClient.java | 345 ++++++++++++++++++ .../service/PublicServiceHandler.java | 23 +- .../service/mongodb/ServiceSearchService.java | 134 +++++++ 12 files changed, 702 insertions(+), 25 deletions(-) rename src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/{PublicServiceMatchedController.java => ServiceMatchedController.java} (89%) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java diff --git a/build.gradle b/build.gradle index baf0348..7d40ded 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,10 @@ dependencies { // 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/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java index 907ebcf..560495b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java @@ -4,7 +4,25 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Arrays; +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/bookmark/service/BookmarkService.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java index fb9d821..989a6e8 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,6 +21,7 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final UserRepository userRepository; private final PublicServiceRepository publicServiceRepository; + private final PublicServiceHandler publicServiceHandler; public void addBookmark(String serviceId, Long userId) { User user = userRepository.findById(userId) @@ -42,6 +44,9 @@ public void addBookmark(String serviceId, Long userId) { // 북마크 수 증가 publicService.increaseBookmarkCount(); + + // 인기 서비스 캐시 무효화 + publicServiceHandler.refreshPopularServices(); } @Transactional @@ -54,5 +59,8 @@ public void removeBookmark(String serviceId, Long userId) { // 북마크 수 감소 PublicService publicService = bookmark.getPublicService(); publicService.decreaseBookmarkCount(); + + // 인기 서비스 캐시 무효화 + publicServiceHandler.refreshPopularServices(); } } 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..85cb793 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,60 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.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.ServiceSearchService; +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 ServiceSearchService 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/PublicServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java similarity index 89% rename from src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java rename to src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java index 5d44241..704344f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -9,7 +9,7 @@ @RestController @RequestMapping("/api/services/matched") @RequiredArgsConstructor -public class PublicServiceMatchedController { +public class ServiceMatchedController { } 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..ebf0818 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java @@ -0,0 +1,43 @@ +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(); + } + + // 검색 조건 유무 확인 + public boolean hasSearchCriteria() { + return StringUtils.hasText(searchTerm); + } +} 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/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/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/mongodb/ServiceSearchClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java new file mode 100644 index 0000000..805f852 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -0,0 +1,345 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import lombok.RequiredArgsConstructor; +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.List; +import java.util.stream.Collectors; + +@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: [ + {exists: {path: "%s", exists: false}}, + {equals: {path: "%s", value: "Y"}} + ] + } + }""".formatted(genderField, genderField)); + } + + // 나이 필수 조건 + if (criteria.getUserAge() != null) { + int age = criteria.getUserAge(); + + // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + {exists: {path: "targetAgeStart", exists: false}}, + {range: {path: "targetAgeStart", lte: %d}} + ] + } + }""".formatted(age)); + + mustClauses.add(""" + { + compound: { + should: [ + {exists: {path: "targetAgeEnd", exists: false}}, + {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; + } + + return ServiceSearchResultDto.of(searchResults, 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 b11117b..0cae309 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -14,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; @@ -48,6 +50,10 @@ public class PublicServiceHandler { }); }*/ + /* public ServiceCategory getServiceCategory(String categoryName) { + return publicServiceValidate.validateServiceCategory(categoryName); + }*/ + // 서비스 상세 조회 @Transactional public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long userId) { @@ -72,7 +78,8 @@ public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long us return dto; } - // 인기 서비스 목록 조회(6개 고정) + // 인기 서비스 목록 조회(6개 고정) - 캐싱적용 + @Cacheable(value = "popularServices", key = "'top6'", unless = "#result.isEmpty()") public List getPopularServices(Long userId) { // 북마크 수 기준으로 상위 6개 서비스 조회 @@ -90,8 +97,9 @@ public List getPopularServices(Long userId) { .collect(Collectors.toList()); } - public ServiceCategory getServiceCategory(String categoryName) { - return publicServiceValidate.validateServiceCategory(categoryName); + // 인기 서비스 캐시 무효화 - 데이터 변경 시 호출 + @CacheEvict(value = "popularServices", key = "'top6'") + public void refreshPopularServices() { } // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) @@ -200,7 +208,8 @@ public Page getAllServices( }); } - // 기존 코드에 아래 메서드 추가 + // 필터 옵션 조회 (캐싱 적용) + @Cacheable(value = "filterOptions") public Map> getFilterOptions() { Map> filterOptions = new HashMap<>(); @@ -225,6 +234,11 @@ public Map> getFilterOptions() { return filterOptions; } + // 필터 옵션 캐시 무효화 - Enum이 변경될 때 + @CacheEvict(value = "filterOptions", allEntries = true) + public void refreshFilterOptions() { + } + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); @@ -239,4 +253,5 @@ 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/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java new file mode 100644 index 0000000..6ab2bf8 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java @@ -0,0 +1,134 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +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 ServiceSearchService { + private final ServiceSearchClient serviceSearchClient; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + + // 기본 검색 (비로그인) + public Page searchServices(ServiceSearchCriteriaDto criteria) { + // 검색 조건이 없는 경우 빈 결과 반환 + if (!criteria.hasSearchCriteria()) { + return Page.empty(criteria.getPageable()); + } + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(criteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 없이) + return convertToPageResponse(searchResult, null); + } + + // 맞춤 검색 (로그인) + public Page searchPersonalizedServices( + ServiceSearchCriteriaDto criteria, Long userId) { + + // 검색 조건이 없는 경우 빈 결과 반환 + if (!criteria.hasSearchCriteria()) { + return Page.empty(criteria.getPageable()); + } + + // 사용자 정보 가져오기 + 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() + ); + } + + // 나이 계산 헬퍼 메서드 + private 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() { + } + +} From 610507dc776c59f72b1f5b32bba8f5584a6f5ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:53:28 +0900 Subject: [PATCH 25/41] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=BA=90=EC=8B=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/CacheConfig.java | 35 +++++++++++++++++++ .../entity/mongodb/CacheType.java | 17 +++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java 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/publicservice/entity/mongodb/CacheType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java new file mode 100644 index 0000000..ecf3b36 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java @@ -0,0 +1,17 @@ +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), // 필터 옵션 캐시 (하루 유지) + POPULAR_SERVICES("popularServices", 1, 100), // 인기 서비스 캐시 + MATCHED_SERVICES("matchedServices", 2, 100); // 맞춤 서비스 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file From f35144cbc98c7abc6d7a952d72a822c9d3a30024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:54:09 +0900 Subject: [PATCH 26/41] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=9D=B5=20=EB=B0=8F=20=EC=A7=81=EC=97=85=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20incomelevel=20?= =?UTF-8?q?=EC=84=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/mongodb/IncomeLevel.java | 38 ++++++ .../mongodb/IncomeEstimationHandler.java | 120 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java 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/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"; // 기본값 + } +} From 248f025d67adc8a09b990ab5f2c21f07bd9f856a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 01:01:49 +0900 Subject: [PATCH 27/41] =?UTF-8?q?feat:=20securityPath=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/common/config/SecurityPath.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 325e229..98abd07 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -14,7 +14,9 @@ public class SecurityPath { "/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") From 12e8a293baf1e3283ed0e885428b6bed708172fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 02:36:44 +0900 Subject: [PATCH 28/41] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=85=EC=84=9C=ED=8A=B8?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 2 +- .../controller/DataAdminController.java | 21 +++++ .../repository/PublicDataMongoRepository.java | 7 ++ .../mongodb/service/CleanupOnService.java | 17 ++++ .../service/PublicDataMongoService.java | 80 ++++++++++++++----- .../service/PublicServiceDataServiceImpl.java | 18 ++--- .../mongodb/ServiceSearchClient.java | 20 ++++- .../service/PublicServiceHandler.java | 1 + 8 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java 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 98abd07..799ce8b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -33,7 +33,7 @@ public class SecurityPath { // 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/publicdata/mongodb/controller/DataAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java new file mode 100644 index 0000000..b874862 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.controller; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/mongo") +@RequiredArgsConstructor +public class DataAdminController { + private final PublicDataMongoService publicDataMongoService; + + @PostMapping("/data/deduplicate") + public ResponseEntity deduplicateMongo() { + publicDataMongoService.deduplicateMongoDocuments(); + return ResponseEntity.ok("MongoDB 중복 데이터 정리가 시작되었습니다."); + } +} 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 index ac8f87d..afea3bf 100644 --- 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 @@ -2,12 +2,19 @@ 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/CleanupOnService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java new file mode 100644 index 0000000..6d55f8e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CleanupOnService { + private final PublicDataMongoService publicDataMongoService; + + @PostConstruct + public void cleanupOnStartup() { + // 서버 시작 시 중복 데이터 정리 실행 + publicDataMongoService.deduplicateMongoDocuments(); + } +} 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 index a03cf59..f5e0279 100644 --- 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 @@ -3,10 +3,14 @@ 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.index.Index; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -29,17 +33,6 @@ public PublicData saveToMongo(PublicService publicService) { return mongoRepository.save(document); } - /** - * 여러 공공서비스 엔티티를 MongoDB에 저장 - */ - public List saveAllToMongo(List publicServices) { - List documents = publicServices.stream() - .map(this::convertToDocument) - .collect(Collectors.toList()); - - return mongoRepository.saveAll(documents); - } - /** * 공공서비스 엔티티를 MongoDB 문서로 변환 */ @@ -109,29 +102,76 @@ public PublicData updateOrCreateDocument(PublicService publicService) { /** * 여러 서비스 문서 업데이트 또는 생성 */ - public List updateOrCreateBulkDocuments(List services) { - // 기존 ID 목록 가져오기 + public void updateOrCreateBulkDocuments(List services) { + // 모든 service ID 목록 List serviceIds = services.stream() .map(PublicService::getId) .collect(Collectors.toList()); - // ID에 해당하는 문서 맵 생성 - Map existingDocsMap = mongoRepository.findAllById(serviceIds).stream() - .collect(Collectors.toMap(PublicData::getPublicServiceId, doc -> doc, (a, b) -> a)); + // 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 유지 + // 기존 문서의 ID 유지 doc.setId(existingDocsMap.get(service.getId()).getId()); } return doc; }) .collect(Collectors.toList()); - // 일괄 저장 - return mongoRepository.saveAll(docsToSave); + // 저장 + mongoRepository.saveAll(docsToSave); + } + + @PostConstruct + public void ensureIndexes() { + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } + + @Transactional + public void deduplicateMongoDocuments() { + log.info("MongoDB 문서 중복 제거 시작"); + + // 1. 모든 publicServiceId 목록 조회 + List allServiceIds = mongoRepository.findAllPublicServiceIds(); + int totalProcessed = 0; + int totalRemoved = 0; + + // 2. 각 ID별로 처리 + for (String serviceId : allServiceIds) { + List duplicates = mongoRepository.findAllByPublicServiceId(serviceId); + + if (duplicates.size() > 1) { + // 가장 최근 문서를 남기고 나머지 삭제 + PublicData latestDoc = duplicates.get(0); // 또는 타임스탬프로 정렬하여 최신 문서 선택 + + // 나머지 중복 문서 삭제 + for (int i = 1; i < duplicates.size(); i++) { + mongoRepository.delete(duplicates.get(i)); + totalRemoved++; + } + } + + totalProcessed++; + if (totalProcessed % 1000 == 0) { + log.info("중복 제거 진행 중: {}/{} 처리, {}개 제거됨", + totalProcessed, allServiceIds.size(), totalRemoved); + } + } + + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 중 {}개 중복 제거됨", + totalProcessed, totalRemoved); } } \ No newline at end of file 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 8f23761..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 @@ -240,8 +240,7 @@ public List upsertServiceData(List= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -249,8 +248,7 @@ public List upsertServiceData(List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 목록 데이터 {}건 저장 완료", validatedData.size()); @@ -296,8 +294,7 @@ public List upsertServiceDetailData(List= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -305,8 +302,7 @@ public List upsertServiceDetailData(List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 상세정보 데이터 {}건 저장 완료", validatedData.size()); @@ -356,8 +352,7 @@ public List upsertSupportConditionsData(Lis // 배치 처리 최적화: 1000개 단위로 저장 if (entitiesToSave.size() >= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -365,8 +360,7 @@ public List upsertSupportConditionsData(Lis // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 지원조건 데이터 {}건 저장 완료", validatedData.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 index 805f852..68ae2e4 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -1,6 +1,7 @@ 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; @@ -14,9 +15,12 @@ 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 { @@ -308,7 +312,21 @@ private ServiceSearchResultDto processResults(AggregationResults resul total = count != null ? count.longValue() : 0L; } - return ServiceSearchResultDto.of(searchResults, total, pageable); + // 중복 제거: 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); } // 검색어 자동완성 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 0cae309..4cd42b7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -239,6 +239,7 @@ public Map> getFilterOptions() { public void refreshFilterOptions() { } + // 내가 북마크한 서비스 목록 조회 public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); From fd5cd351f2f3812397cb9fef30963b966c2e8a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 02:45:45 +0900 Subject: [PATCH 29/41] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=85=EC=84=9C=ED=8A=B8?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mongodb/service/CleanupOnService.java | 17 ---------- .../service/PublicDataMongoService.java | 32 ++++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java deleted file mode 100644 index 6d55f8e..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class CleanupOnService { - private final PublicDataMongoService publicDataMongoService; - - @PostConstruct - public void cleanupOnStartup() { - // 서버 시작 시 중복 데이터 정리 실행 - publicDataMongoService.deduplicateMongoDocuments(); - } -} 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 index f5e0279..da6f5fa 100644 --- 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 @@ -9,6 +9,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -132,12 +133,35 @@ public void updateOrCreateBulkDocuments(List services) { mongoRepository.saveAll(docsToSave); } + // 첫 실행 시에만 중복 제거 및 인덱스 생성 @PostConstruct public void ensureIndexes() { - mongoTemplate.indexOps("service_info").ensureIndex( - new Index().on("publicServiceId", Sort.Direction.ASC).unique() - ); - log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + try { + // 인덱스 존재 여부 확인 + boolean indexExists = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName())) { + indexExists = true; + break; + } + } + + if (!indexExists) { + // 인덱스가 없을 때만 중복 제거 및 인덱스 생성 수행 + log.info("MongoDB 중복 제거 및 인덱스 생성 시작"); + deduplicateMongoDocuments(); + + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } else { + log.info("MongoDB 인덱스 이미 존재함: publicServiceId (unique)"); + } + } catch (Exception e) { + log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); + // 인덱스 생성 실패해도 애플리케이션은 시작되도록 함 + } } @Transactional From 552b2e47553d922016673b4acb7d230ce772bd0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 18:25:50 +0900 Subject: [PATCH 30/41] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9C=A0=EB=8B=88=ED=81=AC?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DataAdminController.java | 21 ----- .../service/PublicDataMongoService.java | 85 +++++++++++++------ 2 files changed, 60 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java deleted file mode 100644 index b874862..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.mongodb.controller; - -import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/admin/mongo") -@RequiredArgsConstructor -public class DataAdminController { - private final PublicDataMongoService publicDataMongoService; - - @PostMapping("/data/deduplicate") - public ResponseEntity deduplicateMongo() { - publicDataMongoService.deduplicateMongoDocuments(); - return ResponseEntity.ok("MongoDB 중복 데이터 정리가 시작되었습니다."); - } -} 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 index da6f5fa..8cd794a 100644 --- 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 @@ -8,8 +8,13 @@ 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; @@ -137,26 +142,42 @@ public void updateOrCreateBulkDocuments(List services) { @PostConstruct public void ensureIndexes() { try { - // 인덱스 존재 여부 확인 - boolean indexExists = false; + // 1. 기존 인덱스 확인 + boolean hasUniqueIndex = false; for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { if ("publicServiceId_1".equals(indexInfo.getName())) { - indexExists = true; + hasUniqueIndex = indexInfo.isUnique(); break; } } - if (!indexExists) { - // 인덱스가 없을 때만 중복 제거 및 인덱스 생성 수행 - log.info("MongoDB 중복 제거 및 인덱스 생성 시작"); + // 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 (unique)"); + log.info("MongoDB 유니크 인덱스 이미 존재함: publicServiceId_1"); } } catch (Exception e) { log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); @@ -166,36 +187,50 @@ public void ensureIndexes() { @Transactional public void deduplicateMongoDocuments() { - log.info("MongoDB 문서 중복 제거 시작"); + 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 + ); - // 1. 모든 publicServiceId 목록 조회 - List allServiceIds = mongoRepository.findAllPublicServiceIds(); int totalProcessed = 0; int totalRemoved = 0; - // 2. 각 ID별로 처리 - for (String serviceId : allServiceIds) { - List duplicates = mongoRepository.findAllByPublicServiceId(serviceId); - - if (duplicates.size() > 1) { - // 가장 최근 문서를 남기고 나머지 삭제 - PublicData latestDoc = duplicates.get(0); // 또는 타임스탬프로 정렬하여 최신 문서 선택 - - // 나머지 중복 문서 삭제 - for (int i = 1; i < duplicates.size(); i++) { - mongoRepository.delete(duplicates.get(i)); - totalRemoved++; + 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, allServiceIds.size(), totalRemoved); + log.info("중복 제거 진행 중: {}/{} 그룹 처리, {}개 제거됨", + totalProcessed, results.getMappedResults().size(), totalRemoved); } } - log.info("MongoDB 문서 중복 제거 완료: 총 {}개 중 {}개 중복 제거됨", + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 그룹 중 {}개 중복 제거됨", totalProcessed, totalRemoved); } } \ No newline at end of file From 44893e702cffe55646fb153dd811dced4d8cd739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 19:33:57 +0900 Subject: [PATCH 31/41] =?UTF-8?q?refactor:=20mongodb=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=97=B0=EC=9E=A5,=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java | 2 +- src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java | 2 +- src/main/resources/application.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java index 0767fbf..8d5cf99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java @@ -15,7 +15,7 @@ public class MyPostListResponseDto { private Long postId; private String title; private String content; - private Long nickName; // 작성자 닉네임 + private String nickName; // 작성자 닉네임 private LocalDateTime createdAt; private int recommendCnt; private int commentCnt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java index e542bd5..c574aac 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -12,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor public class PostCreateRequestDto { - private Long nickName; + private String nickName; private String title; private String content; private LocalDateTime createdAt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index a4ccc5a..2f75f66 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -13,7 +13,7 @@ @AllArgsConstructor public class PostDetailResponseDto { private Long postId; - private Long nickName; // 작성자 닉네임 + private String nickName; // 작성자 닉네임 private String title; private String content; private LocalDateTime createdAt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java index d0b4c98..037592c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -12,7 +12,7 @@ public class PostListResponseDto { private Long postId; private String title; - private Long nickName; // 작성자 닉네임 + 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 index 8d6419c..600b6a2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -12,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor public class PostUpdateRequestDto { - private Long nickName; + private String nickName; private String title; private String content; // private LocalDateTime createdAt; 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 ce3c8fa..c64df29 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -48,7 +48,7 @@ public class Post { private int recommendCnt = 0; // 추천수 @Builder.Default - @Column(name = "view_cnt") + @Column(name = "view_count") private int viewCnt = 0; // 조회수 // TODO: 댓글 생성/수정 시 업데이트 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ea60cb6..7fc6fde 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,7 @@ spring: data: mongodb: - uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&appName=HyetaekOn + 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 From ed692449d51308c890248f59d3178fad5227080b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 22:45:38 +0900 Subject: [PATCH 32/41] =?UTF-8?q?style:=20UserInterest=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=AA=85=20=EC=8A=A4=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 4 +++- .../com/hyetaekon/hyetaekon/user/entity/User.java | 2 +- .../controller/UserInterestController.java | 12 ++++++------ .../dto/CategorizedInterestsResponseDto.java | 3 +-- .../dto/CategorizedInterestsWithSelectionDto.java | 2 +- .../dto/InterestItemDto.java | 2 +- .../dto/InterestSelectionRequestDto.java | 2 +- .../entity/UserInterest.java | 2 +- .../entity/UserInterestEnum.java | 2 +- .../repository/UserInterestRepository.java | 5 ++--- .../service/UserInterestService.java | 12 ++++++------ 11 files changed, 24 insertions(+), 24 deletions(-) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/controller/UserInterestController.java (84%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/dto/CategorizedInterestsResponseDto.java (76%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/dto/CategorizedInterestsWithSelectionDto.java (83%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/dto/InterestItemDto.java (81%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/dto/InterestSelectionRequestDto.java (91%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/entity/UserInterest.java (92%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/entity/UserInterestEnum.java (97%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/repository/UserInterestRepository.java (81%) rename src/main/java/com/hyetaekon/hyetaekon/{UserInterest => userInterest}/service/UserInterestService.java (91%) 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 799ce8b..176ae35 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -28,7 +28,9 @@ public class SecurityPath { "/api/interests", "/api/interests/me", "/api/posts", - "/api/posts/*" + "/api/posts/*", + "/api/search/history", + "/api/search/history/*" }; // hasRole("ADMIN") 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/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 97% 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 815a1b2..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; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java similarity index 81% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java index 560495b..aa1fd77 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java @@ -1,10 +1,9 @@ -package com.hyetaekon.hyetaekon.UserInterest.repository; +package com.hyetaekon.hyetaekon.userInterest.repository; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.Arrays; import java.util.List; @Repository 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; From b946e0ea872cb2c48a0b3bbfd53a86a7037d19b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 22:46:07 +0900 Subject: [PATCH 33/41] =?UTF-8?q?feat:=20redis=EB=A5=BC=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=9C=A0=EC=A0=80=EB=B3=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EA=B8=B0=EB=A1=9D=20=EC=83=9D=EC=84=B1,=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/redis/SearchHistoryConfig.java | 24 +++++ .../dto/mongodb/ServiceSearchCriteriaDto.java | 4 - .../service/mongodb/ServiceSearchService.java | 12 ++- .../searchHistory/Dto/SearchHistoryDto.java | 25 +++++ .../Repository/SearchHistoryRepository.java | 16 ++++ .../Service/SearchHistoryService.java | 94 +++++++++++++++++++ .../controller/SearchHistoryController.java | 49 ++++++++++ .../searchHistory/entity/SearchHistory.java | 36 +++++++ 8 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/searchHistory/Dto/SearchHistoryDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/searchHistory/Repository/SearchHistoryRepository.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java 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/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java index ebf0818..4642d69 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java @@ -36,8 +36,4 @@ public ServiceSearchCriteriaDto withUserInfo( .build(); } - // 검색 조건 유무 확인 - public boolean hasSearchCriteria() { - return StringUtils.hasText(searchTerm); - } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java index 6ab2bf8..f38430b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java @@ -1,7 +1,8 @@ package com.hyetaekon.hyetaekon.publicservice.service.mongodb; -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; -import com.hyetaekon.hyetaekon.UserInterest.repository.UserInterestRepository; +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; @@ -34,11 +35,12 @@ public class ServiceSearchService { private final UserRepository userRepository; private final UserInterestRepository userInterestRepository; private final IncomeEstimationHandler incomeEstimationHandler; + private final SearchHistoryService searchHistoryService; // 기본 검색 (비로그인) public Page searchServices(ServiceSearchCriteriaDto criteria) { // 검색 조건이 없는 경우 빈 결과 반환 - if (!criteria.hasSearchCriteria()) { + if (!StringUtils.hasText(criteria.getSearchTerm())) { return Page.empty(criteria.getPageable()); } @@ -54,8 +56,10 @@ public Page searchPersonalizedServices( ServiceSearchCriteriaDto criteria, Long userId) { // 검색 조건이 없는 경우 빈 결과 반환 - if (!criteria.hasSearchCriteria()) { + if (!StringUtils.hasText(criteria.getSearchTerm())) { return Page.empty(criteria.getPageable()); + } else { // 검색어가 유효하면 검색 기록 저장 + searchHistoryService.saveSearchHistory(userId, criteria.getSearchTerm()); } // 사용자 정보 가져오기 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..7788f72 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -0,0 +1,94 @@ +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) { + if (userId == 0L) { + return List.of(); + } + + 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 (userId == 0L || !StringUtils.hasText(historyId)) { + return; + } + + // 안전하게 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 + searchHistoryRepository.deleteByUserIdAndId(userId, historyId); + log.debug("사용자 {} 검색 기록 삭제: {}", userId, historyId); + } + + /** + * 사용자의 모든 검색 기록 삭제 + */ + @Transactional + public void deleteAllSearchHistories(Long userId) { + if (userId == 0L) { + return; + } + + 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(); + } +} From 610ea32d5627cc7d445281c3a0d36d9dc3529fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Fri, 2 May 2025 02:20:58 +0900 Subject: [PATCH 34/41] =?UTF-8?q?rafactor:=20userId=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20refresh-expired=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../searchHistory/Service/SearchHistoryService.java | 13 ++----------- src/main/resources/application-prod.yml | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java index 7788f72..130b628 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -50,10 +50,6 @@ public void saveSearchHistory(Long userId, String searchTerm) { */ @Transactional(readOnly = true) public List getUserSearchHistories(Long userId) { - if (userId == 0L) { - return List.of(); - } - List histories = searchHistoryRepository.findByUserId(userId); // 최신 순으로 정렬하여 최대 6개 반환 @@ -69,11 +65,10 @@ public List getUserSearchHistories(Long userId) { */ @Transactional public void deleteSearchHistory(Long userId, String historyId) { - if (userId == 0L || !StringUtils.hasText(historyId)) { + if (!StringUtils.hasText(historyId)) { return; } - - // 안전하게 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 + // 사용자의 검색 기록만 삭제하기 위해 사용자 ID도 함께 확인 searchHistoryRepository.deleteByUserIdAndId(userId, historyId); log.debug("사용자 {} 검색 기록 삭제: {}", userId, historyId); } @@ -83,10 +78,6 @@ public void deleteSearchHistory(Long userId, String historyId) { */ @Transactional public void deleteAllSearchHistories(Long userId) { - if (userId == 0L) { - return; - } - List histories = searchHistoryRepository.findByUserId(userId); searchHistoryRepository.deleteAll(histories); log.debug("사용자 {} 검색 기록 전체 삭제", userId); 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 From 92988b0a2934b26357a84900001604aedde6c7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Fri, 2 May 2025 04:59:06 +0900 Subject: [PATCH 35/41] =?UTF-8?q?fix:=20MongoDB=20Atlas=20Search=EC=9D=98?= =?UTF-8?q?=20$search=20=EB=AC=B8=EB=B2=95=EC=97=90=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=ED=95=9C=20=EC=97=B0=EC=82=B0=EC=9E=90=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mongodb/ServiceSearchClient.java | 43 ++++++++++++++----- .../service/mongodb/ServiceSearchService.java | 2 +- 2 files changed, 34 insertions(+), 11 deletions(-) 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 index 68ae2e4..e8693bd 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -160,11 +160,19 @@ private String buildUserMustClause(ServiceSearchCriteriaDto criteria) { { compound: { should: [ - {exists: {path: "%s", exists: false}}, - {equals: {path: "%s", value: "Y"}} + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } ] } - }""".formatted(genderField, genderField)); + } + """.formatted(genderField, genderField)); + } // 나이 필수 조건 @@ -176,21 +184,36 @@ private String buildUserMustClause(ServiceSearchCriteriaDto criteria) { { compound: { should: [ - {exists: {path: "targetAgeStart", exists: false}}, - {range: {path: "targetAgeStart", lte: %d}} + { + compound: { + mustNot: [{exists: {path: "targetAgeStart"}}] + } + }, + { + range: {path: "targetAgeStart", lte: %d} + } ] } - }""".formatted(age)); + } + """.formatted(age)); - mustClauses.add(""" + mustClauses.add(""" { compound: { should: [ - {exists: {path: "targetAgeEnd", exists: false}}, - {range: {path: "targetAgeEnd", gte: %d}} + { + compound: { + mustNot: [{exists: {path: "targetAgeEnd"}}] + } + }, + { + range: {path: "targetAgeEnd", gte: %d} + } ] } - }""".formatted(age)); + } + """.formatted(age)); + } // must 조건이 없으면 빈 문자열 반환 diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java index f38430b..241e093 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java @@ -58,7 +58,7 @@ public Page searchPersonalizedServices( // 검색 조건이 없는 경우 빈 결과 반환 if (!StringUtils.hasText(criteria.getSearchTerm())) { return Page.empty(criteria.getPageable()); - } else { // 검색어가 유효하면 검색 기록 저장 + } else if(StringUtils.hasText(criteria.getSearchTerm())) { // 검색어가 유효하면 검색 기록 저장 searchHistoryService.saveSearchHistory(userId, criteria.getSearchTerm()); } From b03d1b2d0e3c966bfac9bd8a692235dc4db5637b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Fri, 2 May 2025 06:06:58 +0900 Subject: [PATCH 36/41] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 3 +- .../mongodb/SearchInfoController.java | 6 +- .../mongodb/ServiceMatchedController.java | 30 +- .../mongodb/MatchedServiceClient.java | 345 ++++++++++++++++++ .../mongodb/ServiceMatchedHandler.java | 91 +++++ ...Service.java => ServiceSearchHandler.java} | 4 +- 6 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java rename src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/{ServiceSearchService.java => ServiceSearchHandler.java} (98%) diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 176ae35..f477ea8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -30,7 +30,8 @@ public class SecurityPath { "/api/posts", "/api/posts/*", "/api/search/history", - "/api/search/history/*" + "/api/search/history/*", + "/api/mongo/services/matched" }; // hasRole("ADMIN") diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java index 85cb793..9695bc5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java @@ -1,6 +1,5 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; -import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; @@ -8,12 +7,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; -import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchService; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchHandler; import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; import java.util.List; @@ -23,7 +21,7 @@ @RequestMapping("/api/mongo/services") @RequiredArgsConstructor public class SearchInfoController { - private final ServiceSearchService searchService; + private final ServiceSearchHandler searchService; private final AuthenticateUser authenticateUser; // 검색 API (로그인/비로그인 통합) diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java index 704344f..fe59802 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -1,15 +1,43 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceMatchedHandler; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Validated @RestController -@RequestMapping("/api/services/matched") +@RequestMapping("/api/mongo/services/matched") @RequiredArgsConstructor public class ServiceMatchedController { + private final ServiceMatchedHandler serviceMatchedHandler; + + /** + * 사용자 맞춤 공공서비스 추천 API + * 사용자 프로필 및 검색 기록 기반으로 개인화된 서비스 목록 추천 + */ + @GetMapping + public ResponseEntity> getMatchedServices( + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(20) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 사용자 맞춤 추천 서비스 조회 + List matchedServices = + serviceMatchedHandler.getPersonalizedServices(userDetails.getId(), size); + return ResponseEntity.ok(matchedServices); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java new file mode 100644 index 0000000..e0c7241 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java @@ -0,0 +1,345 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MatchedServiceClient { + private final MongoTemplate mongoTemplate; + private static final String INDEX_NAME = "searchIndex"; + private static final String COLLECTION_NAME = "service_info"; + + private static final String PROJECT_STAGE = """ + { + $project: { + publicServiceId: 1, + serviceName: 1, + summaryPurpose: 1, + serviceCategory: 1, + specialGroup: 1, + familyType: 1, + occupations: 1, + businessTypes: 1, + targetGenderMale: 1, + targetGenderFemale: 1, + targetAgeStart: 1, + targetAgeEnd: 1, + incomeLevel: 1, + matchCount: 1, + score: {$meta: 'searchScore'} + } + }"""; + + /** + * 사용자 맞춤 공공서비스 추천 + */ + public ServiceSearchResultDto getMatchedServices( + List keywords, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob, + int size) { + + // 키워드가 없는 경우 빈 결과 반환 + if (keywords == null || keywords.isEmpty()) { + return ServiceSearchResultDto.of(Collections.emptyList(),0L, PageRequest.of(0, size)); + } + + String searchQuery = buildSearchQuery( + keywords, + userGender, + userAge, + userIncomeLevel, + userJob + ); + + // 매칭된 키워드 수를 계산하는 스테이지 + String matchCountStage = buildMatchCountStage(keywords); + + // 매칭된 키워드가 있는 것만 필터링 + String filterStage = "{$match: {matchCount: {$gt: 0}}}"; + + // 매칭 수와 검색 점수로 정렬 + String sortStage = "{$sort: {matchCount: -1, score: -1}}"; + + // 결과 제한 + String limitStage = String.format("{$limit: %d}", size); + + AggregationOperation searchOperation = context -> Document.parse(searchQuery); + AggregationOperation matchCountOperation = context -> Document.parse(matchCountStage); + AggregationOperation filterOperation = context -> Document.parse(filterStage); + AggregationOperation sortOperation = context -> Document.parse(sortStage); + AggregationOperation projectOperation = context -> Document.parse(PROJECT_STAGE); + AggregationOperation limitOperation = context -> Document.parse(limitStage); + + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation( + searchOperation, + matchCountOperation, + filterOperation, + sortOperation, + projectOperation, + limitOperation + ), + COLLECTION_NAME, + Document.class + ); + + return processResults(results, size); + } + + /** + * 검색 쿼리 생성 + */ + private String buildSearchQuery( + List keywords, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + + // should 조건 (가중치가 적용된 키워드 조건) + List shouldClauses = createKeywordMatchClauses(keywords); + + // must 조건 (사용자 조건 필터링) + String mustClause = buildUserMustClause(userGender, userAge); + + // 직업 및 소득 관련 should 조건 추가 + if (StringUtils.hasText(userJob)) { + shouldClauses.add(createSearchClause("occupations", userJob, 3.0f)); + shouldClauses.add(createSearchClause("businessTypes", userJob, 3.0f)); + } + + if (StringUtils.hasText(userIncomeLevel)) { + shouldClauses.add(createSearchClause("incomeLevel", userIncomeLevel, 2.8f)); + shouldClauses.add(createSearchClause("incomeLevel", "ANY", 1.0f)); + // 소득 수준 범위에 따른 가중치 추가 + addIncomeLevelRangeBoosts(shouldClauses, userIncomeLevel); + } + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + String compoundQuery = """ + compound: { + should: %s%s + } + """.formatted(shouldClausesStr, mustClause); + + return """ + { + $search: { + index: '%s', + %s + } + }""".formatted(INDEX_NAME, compoundQuery); + } + + /** + * 키워드 기반 검색 조건 생성 (단일 가중치 적용) + */ + private List createKeywordMatchClauses(List keywords) { + List clauses = new ArrayList<>(); + + // 모든 키워드에 동일한 가중치 부여 + for (String keyword : keywords) { + if (StringUtils.hasText(keyword)) { + // 서비스명 검색 + clauses.add(createSearchClause("serviceName", keyword, 5.0f)); + // 요약 검색 + clauses.add(createSearchClause("summaryPurpose", keyword, 4.0f)); + // 서비스 분야 검색 + clauses.add(createSearchClause("serviceCategory", keyword, 4.5f)); + // 특수그룹 검색 + clauses.add(createSearchClause("specialGroup", keyword, 4.0f)); + // 가족유형 검색 + clauses.add(createSearchClause("familyType", keyword, 4.0f)); + } + } + + return clauses; + } + + /** + * 검색 조건 생성 헬퍼 메서드 + */ + private String createSearchClause(String path, String query, float boost) { + return """ + {text: { + query: '%s', + path: '%s', + score: {boost: {value: %.1f}} + }}""".formatted(query, path, boost); + } + + /** + * 소득수준 범위에 따른 가중치 부여 + */ + private void addIncomeLevelRangeBoosts(List clauses, String userIncomeLevel) { + switch (userIncomeLevel) { + case "HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_HIGH", 2.5f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 1.5f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.0f)); + break; + case "MIDDLE_HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.5f)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f)); + break; + case "MIDDLE": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f)); + break; + case "MIDDLE_LOW": + clauses.add(createSearchClause("incomeLevel", "LOW", 2.0f)); + break; + default: + break; + } + } + + /** + * 사용자 기본 조건(성별, 나이) 필터링 + */ + private String buildUserMustClause(String userGender, Integer userAge) { + List mustClauses = new ArrayList<>(); + + // 성별 필수 조건 + if (StringUtils.hasText(userGender)) { + String genderField = "MALE".equalsIgnoreCase(userGender) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } + ] + } + } + """.formatted(genderField, genderField)); + } + + // 나이 필수 조건 + if (userAge != null) { + int age = userAge; + + // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeStart"}}] + } + }, + { + range: {path: "targetAgeStart", lte: %d} + } + ] + } + } + """.formatted(age)); + + mustClauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeEnd"}}] + } + }, + { + range: {path: "targetAgeEnd", gte: %d} + } + ] + } + } + """.formatted(age)); + } + + // must 조건이 없으면 빈 문자열 반환 + if (mustClauses.isEmpty()) { + return ""; + } + + // must 조건이 있으면 문자열 형식으로 반환 + return ", must: [" + String.join(",", mustClauses) + "]"; + } + + /** + * 매칭된 키워드 수 계산 + */ + private String buildMatchCountStage(List keywords) { + if (keywords == null || keywords.isEmpty()) { + return "{$addFields: {matchCount: 0}}"; + } + + String keywordArray = keywords.stream() + .filter(StringUtils::hasText) + .map(keyword -> "\"" + keyword + "\"") + .collect(Collectors.joining(", ")); + + return """ + {$addFields: { + matchCount: { + $add: [ + {$size: {$ifNull: [{$setIntersection: ["$specialGroup", [%s]]}, []]}}, + {$size: {$ifNull: [{$setIntersection: ["$familyType", [%s]]}, []]}}, + {$cond: [{$in: ["$serviceCategory", [%s]]}, 1, 0]} + ] + } + }} + """.formatted(keywordArray, keywordArray, keywordArray); + } + + /** + * 검색 결과 처리 + */ + private ServiceSearchResultDto processResults(AggregationResults results, int size) { + List resultDocs = results.getMappedResults(); + if (resultDocs.isEmpty()) { + return ServiceSearchResultDto.of(Collections.emptyList(),0L, PageRequest.of(0, size)); + } + + List searchResults = resultDocs.stream() + .map(doc -> mongoTemplate.getConverter().read(ServiceInfo.class, doc)) + .collect(Collectors.toList()); + + return ServiceSearchResultDto.of( + searchResults, + searchResults.size(), + PageRequest.of(0, size) + ); + } +} + diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java new file mode 100644 index 0000000..b88b15f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java @@ -0,0 +1,91 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.mapper.mongodb.ServiceInfoMapper; +import com.hyetaekon.hyetaekon.publicservice.repository.mongodb.MatchedServiceClient; +import com.hyetaekon.hyetaekon.searchHistory.Service.SearchHistoryService; +import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.repository.UserInterestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ServiceMatchedHandler { + private final MatchedServiceClient matchedServiceClient; + private final UserRepository userRepository; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + private final SearchHistoryService searchHistoryService; + private final ServiceSearchHandler serviceSearchHandler; + + /** + * 사용자 맞춤 공공서비스 추천 - 사용자 정보 및 검색 기록 기반 + */ + @Cacheable(value = "matchedServices", key = "#userId", unless = "#result.isEmpty()") + public List getPersonalizedServices(Long userId, int size) { + // 사용자 정보 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID입니다.")); + + // 사용자 키워드 조회 (관심사 + 검색 기록) - 중복 제거 및 필터링 + List userKeywords = Stream.concat( + userInterestRepository.findByUserId(userId).stream() + .map(UserInterest::getInterest), + searchHistoryService.getUserSearchHistories(userId).stream() + .map(SearchHistoryDto::getSearchTerm) + ) + .filter(StringUtils::hasText) + .distinct() + .toList(); + + // 키워드가 없는 경우 빈 목록 반환 + if (userKeywords.isEmpty()) { + return Collections.emptyList(); + } + + // 사용자 소득 수준 추정 + String userIncomeLevel = incomeEstimationHandler.determineIncomeLevelFromJob(user.getJob()); + + // 사용자 나이 계산 + Integer userAge = serviceSearchHandler.calculateAge(user.getBirthAt()); + + // MongoDB 클라이언트를 통한 맞춤 서비스 조회 + ServiceSearchResultDto searchResult = matchedServiceClient.getMatchedServices( + userKeywords, + user.getGender(), + userAge, + userIncomeLevel, + user.getJob(), + size + ); + + // DTO 변환 및 북마크 정보 설정 + return searchResult.getResults().stream() + .map(serviceInfo -> { + PublicServiceListResponseDto dto = serviceInfoMapper.toDto(serviceInfo); + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId( + userId, serviceInfo.getPublicServiceId())); + return dto; + }) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java similarity index 98% rename from src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java rename to src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java index 241e093..dbaea0f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor -public class ServiceSearchService { +public class ServiceSearchHandler { private final ServiceSearchClient serviceSearchClient; private final BookmarkRepository bookmarkRepository; private final ServiceInfoMapper serviceInfoMapper; @@ -116,7 +116,7 @@ private Page convertToPageResponse( } // 나이 계산 헬퍼 메서드 - private Integer calculateAge(LocalDate birthDate) { + public Integer calculateAge(LocalDate birthDate) { if (birthDate == null) return null; return Period.between(birthDate, LocalDate.now()).getYears(); } From 1be961d106aa2313553c890faec8a99709bdce4c Mon Sep 17 00:00:00 2001 From: uk0k Date: Fri, 2 May 2025 13:34:22 +0900 Subject: [PATCH 37/41] =?UTF-8?q?PostController=20annotation=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/post/controller/PostController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ad62b5a..dd07581 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -52,7 +52,7 @@ public ResponseEntity getPost( // ✅ 게시글 생성 @PostMapping public ResponseEntity createPost( - @RequestBody PostCreateRequestDto requestDto, + @ModelAttribute PostCreateRequestDto requestDto, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.createPost(requestDto, userDetails.getId())); } @@ -61,7 +61,7 @@ public ResponseEntity createPost( @PutMapping("/{postId}") public ResponseEntity updatePost( @PathVariable Long postId, - @RequestBody PostUpdateRequestDto updateDto, + @ModelAttribute PostUpdateRequestDto updateDto, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.updatePost(postId, updateDto, userDetails.getId())); } From 94a6c6eabda4b3e129445b8c254aaa0661fc195a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 4 May 2025 01:57:56 +0900 Subject: [PATCH 38/41] =?UTF-8?q?fix:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=B5=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/service/BookmarkService.java | 8 ++------ .../publicservice/entity/mongodb/CacheType.java | 1 - .../repository/PublicServiceRepository.java | 2 -- .../service/PublicServiceHandler.java | 16 ++++++---------- 4 files changed, 8 insertions(+), 19 deletions(-) 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 989a6e8..7e38739 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java @@ -44,9 +44,7 @@ public void addBookmark(String serviceId, Long userId) { // 북마크 수 증가 publicService.increaseBookmarkCount(); - - // 인기 서비스 캐시 무효화 - publicServiceHandler.refreshPopularServices(); + publicServiceRepository.save(publicService); } @Transactional @@ -59,8 +57,6 @@ public void removeBookmark(String serviceId, Long userId) { // 북마크 수 감소 PublicService publicService = bookmark.getPublicService(); publicService.decreaseBookmarkCount(); - - // 인기 서비스 캐시 무효화 - publicServiceHandler.refreshPopularServices(); + publicServiceRepository.save(publicService); } } 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 index ecf3b36..bfb6a2a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java @@ -8,7 +8,6 @@ public enum CacheType { SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 FILTER_OPTIONS("filterOptions", 24, 50), // 필터 옵션 캐시 (하루 유지) - POPULAR_SERVICES("popularServices", 1, 100), // 인기 서비스 캐시 MATCHED_SERVICES("matchedServices", 2, 100); // 맞춤 서비스 캐시 private final String cacheName; 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 ebe778f..c7e436e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -17,7 +17,6 @@ @Repository public interface PublicServiceRepository extends JpaRepository { - Page findByServiceCategory(ServiceCategory category, Pageable pageable); List findTop6ByOrderByBookmarkCntDesc(); @@ -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/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 4cd42b7..3f4eaf2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -29,7 +29,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class PublicServiceHandler { private final PublicServiceRepository publicServiceRepository; private final PublicServiceMapper publicServiceMapper; @@ -79,13 +78,11 @@ public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long us } // 인기 서비스 목록 조회(6개 고정) - 캐싱적용 - @Cacheable(value = "popularServices", key = "'top6'", unless = "#result.isEmpty()") + @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); // 로그인한 사용자는 북마크 여부 확인 @@ -97,12 +94,8 @@ public List getPopularServices(Long userId) { .collect(Collectors.toList()); } - // 인기 서비스 캐시 무효화 - 데이터 변경 시 호출 - @CacheEvict(value = "popularServices", key = "'top6'") - public void refreshPopularServices() { - } - // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) + @Transactional(readOnly = true) public Page getAllServices( String sort, List specialGroups, @@ -209,6 +202,7 @@ public Page getAllServices( } // 필터 옵션 조회 (캐싱 적용) + @Transactional(readOnly = true) @Cacheable(value = "filterOptions") public Map> getFilterOptions() { Map> filterOptions = new HashMap<>(); @@ -235,11 +229,13 @@ public Map> getFilterOptions() { } // 필터 옵션 캐시 무효화 - 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); From 4208e607ae7e661a51b4887e9c9e82dd651e7aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 4 May 2025 18:29:27 +0900 Subject: [PATCH 39/41] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=EC=82=AC=EC=9C=A0=20json=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 3 ++- .../hyetaekon/common/exception/ErrorCode.java | 8 ++++++-- .../hyetaekon/user/controller/UserController.java | 4 ++-- .../hyetaekon/user/dto/UserDeleteRequestDto.java | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java 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 f477ea8..3708117 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -31,7 +31,8 @@ public class SecurityPath { "/api/posts/*", "/api/search/history", "/api/search/history/*", - "/api/mongo/services/matched" + "/api/mongo/services/matched", + "/api/users/reports" }; // hasRole("ADMIN") 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 824cab0..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", "이미 존재하는 아이디입니다."), 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 18b09c0..68ab0d6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -72,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(); } 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; +} From a32e3d4925e009f05fee782a99729131d2fe8d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 4 May 2025 18:33:23 +0900 Subject: [PATCH 40/41] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=83=81=ED=83=9C=20enum=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8,=20=EA=B1=B0=EB=B6=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserAdminController.java | 45 +++++++++++++- .../user/controller/UserReportController.java | 29 +++++++++ .../user/dto/UserReportRequestDto.java | 21 +++++++ .../user/dto/admin/UserReportProcessDto.java | 21 +++++++ .../hyetaekon/user/entity/ReportStatus.java | 17 ++++++ .../hyetaekon/user/entity/UserReport.java | 13 +++- .../user/mapper/UserAdminMapper.java | 1 + .../user/repository/UserReportRepository.java | 15 ++++- .../user/service/UserAdminService.java | 59 ++++++++++++++++++- .../user/service/UserReportService.java | 54 +++++++++++++++++ 10 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java 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/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/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/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..5f9e542 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,24 @@ 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); + + // 특정 사용자가 신고한 내역 조회 + Page findByReporter_Id(Long reporterId, Pageable pageable); + + // 특정 사용자가 신고당한 내역 조회 + Page findByReported_Id(Long reportedId, Pageable pageable); + + // 신고 처리되지 않은 건수 확인 + long countByStatus(ReportStatus status); } 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 From 23644b15d3a25ac9346332c267de6a20f81271a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sun, 4 May 2025 18:34:50 +0900 Subject: [PATCH 41/41] =?UTF-8?q?style:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/user/repository/UserReportRepository.java | 9 --------- 1 file changed, 9 deletions(-) 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 5f9e542..4302435 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java @@ -12,13 +12,4 @@ public interface UserReportRepository extends JpaRepository { // 상태별 신고 목록 조회 Page findByStatus(ReportStatus status, Pageable pageable); - - // 특정 사용자가 신고한 내역 조회 - Page findByReporter_Id(Long reporterId, Pageable pageable); - - // 특정 사용자가 신고당한 내역 조회 - Page findByReported_Id(Long reportedId, Pageable pageable); - - // 신고 처리되지 않은 건수 확인 - long countByStatus(ReportStatus status); }