diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6ad76ef --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy to EC2 with Docker hub + +on: + push: + branches: + - dev + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 소스코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v4.2.2 + + # Docker hub 로그인 + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Docker 이미지 빌드 및 푸시 + - name: Build and push Docker images + uses: docker/build-push-action@v6.16.0 + with: + context: . # Dockerfile 위치 + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/hyetaek-on:latest + + + + # 3. EC2에서 환경 변수를 설정하고 애플리케이션 실행 + - name: Run jar on EC2 with environment variables + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{secrets.EC2_HOST}} + username: ${{secrets.EC2_USERNAME}} + key: ${{ secrets.EC2_PRIVATE_KEY }} + port: 22 + script: | + # Docker Hub 로그인 + echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + # 최신 이미지 가져오기 + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/hyetaek-on:latest + # 기존 컨테이너 중지 및 삭제 + sudo docker stop hyetaek-on || true + sudo docker rm hyetaek-on || true + # 새 컨테이너 실행 + sudo docker run -d \ + --name hyetaek-on \ + --env SPRING_PROFILES_ACTIVE=prod,s3Bucket \ + --env-file ${{ secrets.EC2_TARGET_PATH }}/.env \ + --network hyetaekon-network \ + -p 8080:8080 \ + -v ${{ secrets.EC2_TARGET_PATH }}/logs:/app/logs \ + ${{ secrets.DOCKER_USERNAME }}/hyetaek-on:latest diff --git a/build.gradle b/build.gradle index d19b993..7d40ded 100644 --- a/build.gradle +++ b/build.gradle @@ -66,9 +66,13 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' // MongoDB - /*implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - implementation 'org.mongodb:mongodb-driver-sync' - implementation 'org.mongodb:mongodb-driver-core'*/ + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' +// implementation 'org.mongodb:mongodb-driver-sync' +// implementation 'org.mongodb:mongodb-driver-core' + + // Caffeine + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java b/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java index 2c2faad..8578902 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java +++ b/src/main/java/com/hyetaekon/hyetaekon/HyetaekonApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; @EnableJpaAuditing @SpringBootApplication @EnableScheduling +@EnableMongoRepositories public class HyetaekonApplication { public static void main(String[] args) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java deleted file mode 100644 index 907ebcf..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.hyetaekon.hyetaekon.UserInterest.repository; - -import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface UserInterestRepository extends JpaRepository { - -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java index 3417bdd..45545c0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java @@ -2,10 +2,14 @@ import com.hyetaekon.hyetaekon.answer.dto.AnswerDto; import com.hyetaekon.hyetaekon.answer.service.AnswerService; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -15,27 +19,47 @@ public class AnswerController { private final AnswerService answerService; + // 답변 목록 조회 + // 게시글의 답변 목록 조회 + @GetMapping + public ResponseEntity> getAnswersByPostId( + @PathVariable("postId") Long postId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page answers = answerService.getAnswersByPostId(postId, pageable); + return ResponseEntity.ok(answers); + } + // 답변 작성 @PostMapping - @PreAuthorize("hasRole('USER')") - public ResponseEntity createAnswer(@PathVariable Long postId, @RequestBody AnswerDto answerDto) { - AnswerDto createdAnswer = answerService.createAnswer(postId, answerDto); + public ResponseEntity createAnswer( + @PathVariable("postId") Long postId, + @RequestBody AnswerDto answerDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + AnswerDto createdAnswer = answerService.createAnswer(postId, answerDto, userDetails.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(createdAnswer); } // 답변 채택 @PutMapping("/{answerId}/select") - @PreAuthorize("hasRole('USER')") - public ResponseEntity selectAnswer(@PathVariable Long answerId) { - answerService.selectAnswer(answerId); + public ResponseEntity selectAnswer( + @PathVariable("postId") Long postId, + @PathVariable("answerId") Long answerId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + // 로그인한 사용자의 ID를 서비스에 전달 + answerService.selectAnswer(postId, answerId, userDetails.getId()); return ResponseEntity.ok().build(); } - // 답변 삭제 (관리자만 가능) - @DeleteMapping("/admin/answers/{answerId}") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity deleteAnswer(@PathVariable Long answerId) { - answerService.deleteAnswer(answerId); + // 답변 삭제 (관리자와 작성자만 가능) + @DeleteMapping("/{answerId}") + public ResponseEntity deleteAnswer( + @PathVariable("postId") Long postId, + @PathVariable("answerId") Long answerId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + answerService.deleteAnswer(postId, answerId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java b/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java index d3bd5ce..20ca3a0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java @@ -8,6 +8,7 @@ public class AnswerDto { private Long id; private Long postId; private Long userId; + private String nickname; private String content; private LocalDateTime createdAt; private boolean selected; diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java index a54cbcd..6708558 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java @@ -1,33 +1,69 @@ package com.hyetaekon.hyetaekon.answer.entity; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + import java.time.LocalDateTime; + @Entity @Getter @Setter +@Builder @NoArgsConstructor +@AllArgsConstructor @Table(name = "answer") +@EntityListeners(AuditingEntityListener.class) public class Answer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 답변 ID - @Column(name = "post_id", nullable = false) - private Long postId; // 게시글 ID + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; - @Column(name = "user_id", nullable = false) - private Long userId; // 회원 ID + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; @Column(name = "content", columnDefinition = "TEXT", nullable = false) private String content; // 답변 내용 - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; // 생성일 - @Column(name = "selected", nullable = false) private boolean selected; // 채택 여부 + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "suspend_at") + private LocalDateTime suspendAt; + + // 삭제 처리 + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + // 정지 처리 + public void suspend() { + this.suspendAt = LocalDateTime.now(); + } + + public String getDisplayContent() { + if (this.deletedAt != null) { + return "삭제된 답변입니다."; + } else if (this.suspendAt != null) { + return "관리자에 의해 삭제된 답변입니다."; + } + return content; + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java index 5b87c17..d7342b7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java @@ -4,16 +4,15 @@ import com.hyetaekon.hyetaekon.answer.entity.Answer; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.factory.Mappers; +import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface AnswerMapper { - AnswerMapper INSTANCE = Mappers.getMapper(AnswerMapper.class); - + @Mapping(target = "content", expression = "java(answer.getDisplayContent())") + @Mapping(source = "user.id", target = "userId") + @Mapping(source = "post.id", target = "postId") + @Mapping(source = "user.nickname", target = "nickname") AnswerDto toDto(Answer answer); - @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 - @Mapping(target = "selected", ignore = true) // 기본값 false 처리 - @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 Answer toEntity(AnswerDto answerDto); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java index 6a587aa..e75c670 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java @@ -1,9 +1,28 @@ package com.hyetaekon.hyetaekon.answer.repository; import com.hyetaekon.hyetaekon.answer.entity.Answer; +import com.hyetaekon.hyetaekon.post.entity.Post; +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 AnswerRepository extends JpaRepository { + // 페이지네이션 적용한 답변 목록 조회 (post 객체 사용) + Page findByPost(Post post, Pageable pageable); + + // 채택 여부 및 등록일 기준 정렬된 페이징 처리된 답변 목록 조회 + Page findByPostOrderBySelectedDescCreatedAtDesc(Post post, Pageable pageable); + + // 삭제된 답변만 조회 (관리자용) + Page findByPostAndDeletedAtIsNotNull(Post post, Pageable pageable); + + // 정지된 답변만 조회 (관리자용) + Page findByPostAndSuspendAtIsNotNull(Post post, Pageable pageable); + + // 통계용 카운팅 메서드들 + long countByPost(Post post); + long countByPostAndDeletedAtIsNotNull(Post post); + long countByPostAndSuspendAtIsNotNull(Post post); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java index 04a5eaa..775279c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java @@ -4,8 +4,19 @@ import com.hyetaekon.hyetaekon.answer.entity.Answer; import com.hyetaekon.hyetaekon.answer.mapper.AnswerMapper; import com.hyetaekon.hyetaekon.answer.repository.AnswerRepository; -import jakarta.persistence.EntityNotFoundException; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +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.user.entity.PointActionType; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import com.hyetaekon.hyetaekon.user.service.UserPointService; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; @Service @@ -13,22 +24,85 @@ public class AnswerService { private final AnswerRepository answerRepository; private final AnswerMapper answerMapper; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final UserPointService userPointService; + + // 게시글에 답변 목록 조회 + public Page getAnswersByPostId(Long postId, Pageable pageable) { + // 게시글 존재 여부 확인 + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new GlobalException(ErrorCode.POST_NOT_FOUND_BY_ID)); + + // 채택된 답변이 먼저 나오고, 그 다음 최신순으로 정렬 + Page answersPage = answerRepository.findByPostOrderBySelectedDescCreatedAtDesc(post, pageable); + + return answersPage.map(answerMapper::toDto); + } + + public AnswerDto createAnswer(Long postId, AnswerDto answerDto, Long userId) { + // 게시글과 사용자 객체 조회 + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new GlobalException(ErrorCode.POST_NOT_FOUND_BY_ID)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - public AnswerDto createAnswer(Long postId, AnswerDto answerDto) { Answer answer = answerMapper.toEntity(answerDto); - answer.setPostId(postId); + answer.setPost(post); + answer.setUser(user); answer = answerRepository.save(answer); + + userPointService.addPointForAction(userId, PointActionType.ANSWER_CREATION); + return answerMapper.toDto(answer); } - public void selectAnswer(Long answerId) { + public void selectAnswer(Long postId, Long answerId, Long userId) { + // 게시글 조회 + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new GlobalException(ErrorCode.POST_NOT_FOUND_BY_ID)); + + // 요청자가 게시글 작성자인지 확인 + if (!post.getUser().getId().equals(userId)) { + throw new AccessDeniedException("게시글 작성자만 답변을 채택할 수 있습니다."); + } + + // 답변 조회 Answer answer = answerRepository.findById(answerId) - .orElseThrow(() -> new EntityNotFoundException("Answer not found")); + .orElseThrow(() -> new GlobalException(ErrorCode.ANSWER_NOT_FOUND)); + + // 답변이 해당 게시글에 속하는지 확인 + if (!answer.getPost().getId().equals(postId)) { // post 객체 사용 + throw new GlobalException(ErrorCode.ANSWER_NOT_MATCHED_POST); + } + + // 답변 채택 처리 answer.setSelected(true); answerRepository.save(answer); + + // 답변 작성자에게 포인트 부여 + userPointService.addPointForAction(answer.getUser().getId(), PointActionType.ANSWER_ACCEPTED); // user 객체 사용 } - public void deleteAnswer(Long answerId) { - answerRepository.deleteById(answerId); + @Transactional + public void deleteAnswer(Long postId, Long answerId, Long userId, String role) { + Answer answer = answerRepository.findById(answerId) + .orElseThrow(() -> new GlobalException(ErrorCode.ANSWER_NOT_FOUND)); + + if (!answer.getPost().getId().equals(postId)) { // post 객체 사용 + throw new GlobalException(ErrorCode.ANSWER_NOT_MATCHED_POST); + } + + // 작성자 또는 관리자 확인 + boolean isOwner = answer.getUser().getId().equals(userId); // user 객체 사용 + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("답변 삭제 권한이 없습니다"); + } + + answer.delete(); // soft delete 사용 + answerRepository.save(answer); } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java b/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java index 89b9a1e..3efea34 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java @@ -15,41 +15,34 @@ public class BannerController { private final BannerService bannerService; - // 배너 목록 조회 (ALL) @GetMapping public ResponseEntity> getBanners() { return ResponseEntity.ok(bannerService.getBanners()); } - // 배너 상세 조회 (ALL) @GetMapping("/{bannerId}") public ResponseEntity getBanner(@PathVariable Long bannerId) { return ResponseEntity.ok(bannerService.getBanner(bannerId)); } - // 배너 목록 조회 (ADMIN) @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> getAdminBanners() { return ResponseEntity.ok(bannerService.getAdminBanners()); } - // 배너 등록 (ADMIN) @PostMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createBanner(@RequestBody BannerDto bannerDto) { return ResponseEntity.ok(bannerService.createBanner(bannerDto)); } - // 배너 수정 (ADMIN) @PutMapping("/admin/{bannerId}") @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity updateBanner( - @PathVariable Long bannerId, @RequestBody BannerDto bannerDto) { + public ResponseEntity updateBanner(@PathVariable Long bannerId, @RequestBody BannerDto bannerDto) { return ResponseEntity.ok(bannerService.updateBanner(bannerId, bannerDto)); } - // 배너 삭제 (ADMIN) @DeleteMapping("/admin/{bannerId}") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteBanner(@PathVariable Long bannerId) { @@ -57,7 +50,6 @@ public ResponseEntity deleteBanner(@PathVariable Long bannerId) { return ResponseEntity.noContent().build(); } - // 배너 순서 변경 (ADMIN) @PatchMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity updateBannerOrder(@RequestBody List bannerIds) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java b/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java index f9b4852..f35cd70 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java @@ -12,25 +12,37 @@ @NoArgsConstructor @Table(name = "banner") public class Banner { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; // 배너 ID + private Long id; - @Column(name = "title", nullable = false, length = 100) - private String title; // 배너 제목 + @Column(nullable = false, length = 100) + private String title; @Column(name = "image_url", nullable = false, length = 500) - private String imageUrl; // 배너 이미지 URL + private String imageUrl; @Column(name = "link_url", length = 500) - private String linkUrl; // 배너 클릭 시 이동할 URL + private String linkUrl; @Column(name = "display_order", nullable = false) - private int displayOrder; // 배너 정렬 순서 + private int displayOrder; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; // 생성일 + private LocalDateTime createdAt; @Column(name = "updated_at") - private LocalDateTime updatedAt; // 수정일 + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java index 526e7a9..cfc05ae 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java @@ -4,14 +4,12 @@ import com.hyetaekon.hyetaekon.banner.entity.Banner; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.factory.Mappers; +import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface BannerMapper { - BannerMapper INSTANCE = Mappers.getMapper(BannerMapper.class); - BannerDto toDto(Banner banner); - @Mapping(target = "id", ignore = true) // id는 자동 생성됨 + @Mapping(target = "id", ignore = true) Banner toEntity(BannerDto bannerDto); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java b/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java index 29c2bb9..369a48c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java @@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -15,34 +14,28 @@ @RequiredArgsConstructor public class BannerService { private final BannerRepository bannerRepository; + private final BannerMapper bannerMapper; - // 배너 목록 조회 (모든 사용자) public List getBanners() { - List banners = bannerRepository.findAllByOrderByDisplayOrderAsc(); - return banners.stream().map(BannerMapper.INSTANCE::toDto).collect(Collectors.toList()); + return bannerRepository.findAllByOrderByDisplayOrderAsc() + .stream().map(bannerMapper::toDto).collect(Collectors.toList()); } - // 배너 상세 조회 (모든 사용자) public BannerDto getBanner(Long bannerId) { Banner banner = bannerRepository.findById(bannerId) .orElseThrow(() -> new IllegalArgumentException("해당 배너가 존재하지 않습니다.")); - return BannerMapper.INSTANCE.toDto(banner); + return bannerMapper.toDto(banner); } - // 배너 목록 조회 (관리자) public List getAdminBanners() { return getBanners(); } - // 배너 등록 (관리자) public BannerDto createBanner(BannerDto bannerDto) { - Banner banner = BannerMapper.INSTANCE.toEntity(bannerDto); - banner.setCreatedAt(LocalDateTime.now()); - banner.setUpdatedAt(LocalDateTime.now()); - return BannerMapper.INSTANCE.toDto(bannerRepository.save(banner)); + Banner banner = bannerMapper.toEntity(bannerDto); + return bannerMapper.toDto(bannerRepository.save(banner)); } - // 배너 수정 (관리자) public BannerDto updateBanner(Long bannerId, BannerDto bannerDto) { Banner banner = bannerRepository.findById(bannerId) .orElseThrow(() -> new IllegalArgumentException("해당 배너가 존재하지 않습니다.")); @@ -51,17 +44,14 @@ public BannerDto updateBanner(Long bannerId, BannerDto bannerDto) { banner.setImageUrl(bannerDto.getImageUrl()); banner.setLinkUrl(bannerDto.getLinkUrl()); banner.setDisplayOrder(bannerDto.getDisplayOrder()); - banner.setUpdatedAt(LocalDateTime.now()); - return BannerMapper.INSTANCE.toDto(bannerRepository.save(banner)); + return bannerMapper.toDto(bannerRepository.save(banner)); } - // 배너 삭제 (관리자) public void deleteBanner(Long bannerId) { bannerRepository.deleteById(bannerId); } - // 배너 순서 변경 (관리자) @Transactional public void updateBannerOrder(List bannerIds) { int order = 1; 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/entity/Bookmark.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java index 1721ad2..d0ccaeb 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java @@ -21,12 +21,12 @@ public class Bookmark extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JsonIgnore @JoinColumn(name = "public_service_id", nullable = false) private PublicService publicService; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; 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..6ffc1f6 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,8 @@ 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.publicservice.service.mongodb.ServiceMatchedHandler; import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import jakarta.transaction.Transactional; @@ -20,8 +22,9 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final UserRepository userRepository; private final PublicServiceRepository publicServiceRepository; + private final ServiceMatchedHandler serviceMatchedHandler; - public void addBookmark(Long serviceId, Long userId) { + public void addBookmark(String serviceId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(BOOKMARK_USER_NOT_FOUND)); @@ -42,10 +45,13 @@ public void addBookmark(Long serviceId, Long userId) { // 북마크 수 증가 publicService.increaseBookmarkCount(); + publicServiceRepository.save(publicService); + // 캐시 무효화 추가 + serviceMatchedHandler.refreshMatchedServicesCache(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)); @@ -54,5 +60,8 @@ public void removeBookmark(Long serviceId, Long userId) { // 북마크 수 감소 PublicService publicService = bookmark.getPublicService(); publicService.decreaseBookmarkCount(); + publicServiceRepository.save(publicService); + // 캐시 무효화 추가 + serviceMatchedHandler.refreshMatchedServicesCache(userId); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java index e9f9384..50c6089 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java @@ -1,26 +1,141 @@ package com.hyetaekon.hyetaekon.chatbot.controller; import com.hyetaekon.hyetaekon.chatbot.dto.ChatbotDto; -import com.hyetaekon.hyetaekon.chatbot.service.ChatbotService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/chatbot") @RequiredArgsConstructor public class ChatbotController { - private final ChatbotService chatbotService; - // 📌 사용자가 질문하면 답변을 반환하는 API @GetMapping public ResponseEntity getAnswer(@RequestParam String question) { - return ResponseEntity.ok(chatbotService.getAnswer(question)); + + switch (question) { + + // ✅ 초기 인사 + case "처음": + return ok("처음", "안녕하세요! 혜택온 챗봇입니다. 무엇을 도와드릴까요?", + List.of("혜택온 사이트 소개", "포인트 제도 설명", + "커뮤니티 이용 가이드", "신고 및 제재 기준")); + + // ✅ 혜택온 사이트 소개 + case "혜택온 사이트 소개": + return ok(question, "혜택온은 복지 정보를 확인하고, 커뮤니티에서 의견을 나눌 수 있는 플랫폼입니다.\n궁금한 주제를 선택해주세요.", + List.of("복지 서비스", "가구 형태 기준")); + + case "복지 서비스": + return ok(question, "복지 서비스는 주거, 보육, 교육, 고용, 의료, 돌봄 등 여러 분야로 구성되어 있어요.", null); + + case "가구 형태 기준": + return ok(question, "1인가구, 다자녀 가구, 청년가구, 노인가구, 장애인가구 등 다양한 형태로 구분돼요.", null); + + // ✅ 포인트 제도 + case "포인트 제도 설명": + return ok(question, "혜택온에서는 활동에 따라 포인트가 부여되며, 등급이 상승합니다. 자세한 내용을 선택해주세요.", + List.of("포인트 획득 방법", "등급별 혜택")); + + case "포인트 획득 방법": + return ok(question, + "- 회원가입 후 인사 게시판 글 작성 시 100점\n" + + "- 일반 게시판 글 작성 시 20점\n" + + "- 게시글에 답변 작성 시 10점 (한 게시글당 최대 50점)\n" + + "- 답변이 채택되면 추가 50점\n" + + "※ 무의미한 답변 작성 시 관리자가 포인트 감점 또는 등급 하락 조치할 수 있습니다.", + null); + + case "등급별 혜택": + return ok(question, + "포인트 등급은 아래와 같이 구분됩니다:\n" + + "- 0점: 물음표\n" + + "- 100점: 알\n" + + "- 300점: 병아리\n" + + "- 500점: 닭\n" + + "- 700점: 독수리\n" + + "- 1000점 이상: 구름", + null); + + // ✅ 커뮤니티 이용 가이드 + case "커뮤니티 이용 가이드": + return ok(question, "커뮤니티에서는 다음과 같은 기능들을 사용할 수 있어요. 궁금한 항목을 선택해주세요.", + List.of("게시글 작성 방법", "댓글/대댓글 기능", "좋아요 및 공감", "북마크 사용법", "댓글 채택")); + + case "게시글 작성 방법": + return ok(question, + "게시글은 제목(20자 이내), 내용(500자 이내)으로 작성할 수 있으며,\n" + + "이미지 첨부(5MB), 태그, 관련 링크 추가도 가능합니다.\n" + "단, 규칙에 어긋나는 표현은 삭제 또는 제재될 수 있어요.", + null); + + case "댓글/대댓글 기능": + return ok(question, + "게시글에는 댓글을 작성할 수 있고, 댓글에는 대댓글도 작성 가능합니다.\n" + + "단, 규칙에 어긋나는 표현은 삭제 또는 제재될 수 있어요.", + null); + + case "좋아요 및 공감": + return ok(question, + "게시글과 댓글에는 좋아요 버튼이 있습니다.\n" + + "유저 활동 점수에 반영되진 않지만 인기 콘텐츠를 판단하는 기준이 돼요.", + null); + + case "북마크 사용법": + return ok(question, + "관심 있는 게시글 우측 상단의 북마크 아이콘을 클릭하면\n" + + "마이페이지에서 따로 모아볼 수 있습니다.", + null); + + case "댓글 채택": + return ok(question, + "질문 게시판에서 답변이 마음에 들 경우 '채택' 버튼을 눌러 채택할 수 있으며,\n" + + "채택된 유저는 50포인트를 추가로 획득합니다.", + null); + + // ✅ 신고 및 제재 기준 + case "신고 및 제재 기준": + return ok(question, "다음 항목 중 신고 사유를 선택하면 제재 기준을 안내해 드릴게요.", + List.of("음란/선정적인 내용", "스팸/광고", "욕설/비하", "폭력성/위협", + "개인 정보 노출", "허위 정보", "도배/복제/악성 행위", "기타")); + + case "음란/선정적인 내용": + return ok(question, "1차: 10일 정지 / 2차: 30일 정지 / 3차: 영구정지", null); + + case "스팸/광고": + return ok(question, "스팸, 광고, 무단 홍보 게시글은 3일 정지 조치됩니다.", null); + + case "욕설/비하": + return ok(question, "욕설, 인신공격, 비하 발언은 1일 정지 처리됩니다.", null); + + case "폭력성/위협": + return ok(question, "폭력적이거나 위협적인 내용은 10일~영구정지까지 조치됩니다.", null); + + case "개인 정보 노출": + return ok(question, "타인의 개인정보 노출은 10일~영구정지 대상입니다.", null); + + case "허위 정보": + return ok(question, "허위사실 유포 시 3일 정지 조치됩니다.", null); + + case "도배/복제/악성 행위": + return ok(question, "같은 내용 반복, 복사/붙여넣기 등은 1일 정지입니다.", null); + + case "기타": + return ok(question, "사례별로 관리자가 판단하여 별도 조치가 적용됩니다.", null); + + // ✅ 기본 응답 + default: + return ok(question, "죄송해요, 해당 질문에 대한 답변을 찾을 수 없어요.", null); + } } - // 📌 새로운 질문-답변을 DB에 추가하는 API (관리자용) - @PostMapping("/add") - public ResponseEntity addQuestionAndAnswer(@RequestBody ChatbotDto chatbotDto) { - return ResponseEntity.ok(chatbotService.addQuestionAndAnswer(chatbotDto)); + // ✅ 공통 응답 생성 메서드 + private ResponseEntity ok(String question, String answer, List options) { + return ResponseEntity.ok(ChatbotDto.builder() + .question(question) + .answer(answer) + .options(options) + .build()); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java index 929acdc..ee0218e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java @@ -1,13 +1,18 @@ package com.hyetaekon.hyetaekon.chatbot.dto; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @NoArgsConstructor @AllArgsConstructor +@Builder public class ChatbotDto { private String question; private String answer; + private List options; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java index af93923..16ba886 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java @@ -24,7 +24,7 @@ public ChatbotDto getAnswer(String question) { } // 📌 질문이 DB에 없을 경우 기본 응답 반환 - return new ChatbotDto(question, "죄송해요, 해당 질문에 대한 답변을 찾을 수 없어요."); + return new ChatbotDto(question, "죄송해요, 해당 질문에 대한 답변을 찾을 수 없어요.", null); } // 📌 새로운 질문-답변을 DB에 추가하는 메서드 diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java index c0c314a..940f140 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java @@ -1,12 +1,14 @@ package com.hyetaekon.hyetaekon.comment.controller; -import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.service.CommentService; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -18,48 +20,58 @@ public class CommentController { // 게시글 댓글 목록 조회 (페이징 지원) @GetMapping - @PreAuthorize("hasRole('USER')") - public ResponseEntity> getComments(@PathVariable Long postId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - Page comments = commentService.getComments(postId, page, size); + public ResponseEntity> getComments( + @PathVariable("postId") Long postId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page comments = commentService.getComments(postId, page, size); return ResponseEntity.ok(comments); } // 게시글에 댓글 작성 @PostMapping - @PreAuthorize("hasRole('USER')") - public ResponseEntity createComment(@PathVariable Long postId, @RequestBody CommentDto commentDto) { - CommentDto createdComment = commentService.createComment(postId, commentDto); + public ResponseEntity createComment( + @PathVariable("postId") Long postId, + @RequestBody CommentCreateRequestDto commentDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + CommentListResponseDto createdComment = commentService.createComment(postId, userDetails.getId(), commentDto); return ResponseEntity.status(HttpStatus.CREATED).body(createdComment); } // 대댓글 목록 조회 @GetMapping("/{commentId}/replies") - @PreAuthorize("hasRole('USER')") - public ResponseEntity> getReplies(@PathVariable Long postId, - @PathVariable Long commentId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "5") int size) { - Page replies = commentService.getReplies(postId, commentId, page, size); + public ResponseEntity> getReplies( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size) { + Page replies = commentService.getReplies(postId, commentId, page, size); return ResponseEntity.ok(replies); } - // 대댓글 작성 + //대댓글 작성 @PostMapping("/{commentId}/replies") - @PreAuthorize("hasRole('USER')") - public ResponseEntity createReply(@PathVariable Long postId, - @PathVariable Long commentId, - @RequestBody CommentDto commentDto) { - CommentDto createdReply = commentService.createReply(postId, commentId, commentDto); + public ResponseEntity createReply( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @RequestBody CommentCreateRequestDto commentDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 대댓글의 부모 댓글 ID 설정 + commentDto.setParentId(commentId); + + CommentListResponseDto createdReply = commentService.createComment(postId, userDetails.getId(), commentDto); return ResponseEntity.status(HttpStatus.CREATED).body(createdReply); } - // 댓글 삭제 (관리자만 가능) - @DeleteMapping("/admin/comments/{commentId}") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity deleteComment(@PathVariable Long commentId) { - commentService.deleteComment(commentId); + // 댓글 삭제 (관리자나 댓글 작성자만 가능) + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + commentService.deleteComment(commentId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java new file mode 100644 index 0000000..f6661ab --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentCreateRequestDto { + private Long parentId; + private String content; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java similarity index 81% rename from src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java rename to src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java index eed3798..be0eaa0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java @@ -9,10 +9,12 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class CommentDto { +public class CommentListResponseDto { private Long id; private Long postId; + private Long userId; private Long parentId; // 대댓글일 경우 부모 댓글 ID private String content; + private String nickname; private LocalDateTime createdAt; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java index bf9281e..4f34fd4 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java @@ -1,30 +1,68 @@ package com.hyetaekon.hyetaekon.comment.entity; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder @NoArgsConstructor @AllArgsConstructor @Table(name = "comments") +@EntityListeners(AuditingEntityListener.class) public class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private Long postId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; private Long parentId; // 대댓글이면 부모 댓글 ID, 아니면 null @Column(nullable = false, length = 1000) private String content; - private LocalDateTime createdAt = LocalDateTime.now(); + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "suspend_at") + private LocalDateTime suspendAt; + + // 삭제 처리 + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + // 정지 처리 + public void suspend() { + this.suspendAt = LocalDateTime.now(); + } + + public String getDisplayContent() { + if (this.deletedAt != null) { + return "삭제된 댓글입니다."; + } else if (this.suspendAt != null) { + return "관리자에 의해 삭제된 댓글입니다."; + } + return content; + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java index 61fa050..b96ddf8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java @@ -1,18 +1,21 @@ package com.hyetaekon.hyetaekon.comment.mapper; -import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.entity.Comment; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.factory.Mappers; +import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface CommentMapper { - CommentMapper INSTANCE = Mappers.getMapper(CommentMapper.class); - CommentDto toDto(Comment comment); + Comment toEntity(CommentCreateRequestDto requestDto); - @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 - @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 - Comment toEntity(CommentDto commentDto); -} + @Mapping(source = "user.id", target = "userId") + @Mapping(source = "user.nickname", target = "nickname") + @Mapping(source = "post.id", target = "postId") + @Mapping(target = "content", expression = "java(comment.getDisplayContent())") + CommentListResponseDto toResponseDto(Comment comment); + +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java index f31d589..6c40166 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.comment.repository; import com.hyetaekon.hyetaekon.comment.entity.Comment; +import com.hyetaekon.hyetaekon.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,6 +9,20 @@ @Repository public interface CommentRepository extends JpaRepository { - Page findByPostId(Long postId, Pageable pageable); - Page findByPostIdAndParentId(Long postId, Long parentId, Pageable pageable); + // 게시글의 최상위 댓글 조회 (삭제되지 않은 댓글만) + Page findByPostAndParentIdIsNull(Post post, Pageable pageable); + + // 특정 댓글의 대댓글 조회 (삭제되지 않은 댓글만) + Page findByPostAndParentId(Post post, Long parentId, Pageable pageable); + + // 삭제된 댓글만 조회 (관리자용) + Page findByPostAndDeletedAtIsNotNull(Post post, Pageable pageable); + + // 정지된 댓글만 조회 (관리자용) + Page findByPostAndSuspendAtIsNotNull(Post post, Pageable pageable); + + // 통계용 카운팅 메서드들 + long countByPost(Post post); + long countByPostAndDeletedAtIsNotNull(Post post); + long countByPostAndSuspendAtIsNotNull(Post post); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java index 3790730..c1e8ef2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java @@ -1,49 +1,105 @@ package com.hyetaekon.hyetaekon.comment.service; -import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.entity.Comment; import com.hyetaekon.hyetaekon.comment.mapper.CommentMapper; import com.hyetaekon.hyetaekon.comment.repository.CommentRepository; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + @Service @RequiredArgsConstructor public class CommentService { private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; private final CommentMapper commentMapper; - public Page getComments(Long postId, int page, int size) { + /** + * 게시글의 댓글 목록 조회 + */ + @Transactional(readOnly = true) + public Page getComments(Long postId, int page, int size) { Pageable pageable = PageRequest.of(page, size); - return commentRepository.findByPostId(postId, pageable) - .map(commentMapper::toDto); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + return commentRepository.findByPostAndParentIdIsNull(post, pageable) + .map(commentMapper::toResponseDto); } - public CommentDto createComment(Long postId, CommentDto commentDto) { - Comment comment = commentMapper.toEntity(commentDto); - comment.setPostId(postId); + /** + * 댓글 생성 + */ + @Transactional + public CommentListResponseDto createComment(Long postId, Long userId, CommentCreateRequestDto requestDto) { + // 사용자와 게시글 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다")); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + // 엔티티 생성 및 설정 + Comment comment = commentMapper.toEntity(requestDto); + comment.setUser(user); + comment.setPost(post); + + // 저장 및 DTO 변환 comment = commentRepository.save(comment); - return commentMapper.toDto(comment); + return commentMapper.toResponseDto(comment); } - public Page getReplies(Long postId, Long commentId, int page, int size) { + /** + * 대댓글 목록 조회 + */ + @Transactional(readOnly = true) + public Page getReplies(Long postId, Long commentId, int page, int size) { Pageable pageable = PageRequest.of(page, size); - return commentRepository.findByPostIdAndParentId(postId, commentId, pageable) - .map(commentMapper::toDto); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + return commentRepository.findByPostAndParentId(post, commentId, pageable) + .map(commentMapper::toResponseDto); + } + + /** + * 댓글 삭제 (본인 또는 관리자만 가능) + */ + @Transactional + public void deleteComment(Long commentId, Long userId, String role) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new RuntimeException("댓글을 찾을 수 없습니다")); + + // 작성자 또는 관리자 확인 + boolean isOwner = comment.getUser().getId().equals(userId); + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("댓글 삭제 권한이 없습니다"); + } + + // Soft Delete 처리 + comment.delete(); + commentRepository.save(comment); } - public CommentDto createReply(Long postId, Long commentId, CommentDto commentDto) { + /*public CommentDto createReply(Long postId, Long commentId, CommentDto commentDto) { Comment reply = commentMapper.toEntity(commentDto); reply.setPostId(postId); reply.setParentId(commentId); reply = commentRepository.save(reply); return commentMapper.toDto(reply); - } + }*/ - public void deleteComment(Long commentId) { - commentRepository.deleteById(commentId); - } } 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..a82f679 --- /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.CacheType; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java index a8fc1ca..719bb1e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -49,8 +50,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth + .requestMatchers("/api/posts/type", "/api/posts/type/**", "/api/posts/search").permitAll() .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") - .requestMatchers(SecurityPath.USER_ENDPOINTS).hasRole("USER") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() .anyRequest().permitAll() ); @@ -72,8 +74,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration(); configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 - configuration.addAllowedOrigin("https://hyetaek-on.site"); // 혜택온 도메인 - configuration.addAllowedOrigin("https://www.hyetaek-on.site"); + configuration.addAllowedOrigin("https://hyetaek-on.co.kr"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.hyetaek-on.co.kr"); configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 configuration.addAllowedHeader("*"); // 모든 헤더 허용 configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 2d23372..57ed19a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -11,26 +11,41 @@ public class SecurityPath { "/api/users/check-duplicate", "/", "/api/services", + "/api/services/popular", "/api/services/category/*", "/api/services/detail/*", - "/api/public-data/serviceList/test" + "/api/public-data/serviceList/test", + "/api/mongo/search/services", + "/api/mongo/search/posts", + "/api/mongo/search/services/autocomplete" }; - // hasRole("USER") public static final String[] USER_ENDPOINTS = { - "/api/users/me/**", "/api/users/me", + "/api/users/me/**", "/api/logout", - "/api/services/popular", "/api/services/*/bookmark", "/api/interests", - "/api/interests/me" + "/api/interests/me", + "/api/posts", + "/api/posts/*", + "/api/search/history", + "/api/search/history/*", + "/api/mongo/services/matched", + "/api/users/reports", + "/api/posts/*/answers", + "/api/posts/*/answers/*", + "/api/posts/*/answers/*/select", + "/api/posts/*/comments", + "/api/posts/*/comments/*", + "/api/posts/*/comments/*/replies", + "/api/services/recent" }; // hasRole("ADMIN") public static final String[] ADMIN_ENDPOINTS = { - "/api/admin/users/**", + "/api/admin/**", "/api/public-data/serviceDetailList", "/api/public-data/supportConditionsList", "/api/public-data/serviceList" diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java new file mode 100644 index 0000000..2f2e137 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/SearchHistoryConfig.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.common.config.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories(basePackages = "com.hyetaekon.hyetaekon.publicservice.repository.redis") +public class SearchHistoryConfig { + + @Bean + public RedisTemplate searchHistoryRedisTemplate(RedisTemplate redisTemplate) { + // 키와 값의 직렬화 방식 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + + return redisTemplate; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java b/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java index 1ec1b35..ed9915f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/converter/ServiceCategoryConverter.java @@ -6,7 +6,9 @@ import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Converter(autoApply = true) // autoApply를 true로 설정하면 @Convert없이 해당 타입에 대해 자동으로 변환 public class ServiceCategoryConverter implements AttributeConverter { @@ -17,12 +19,20 @@ public String convertToDatabaseColumn(ServiceCategory serviceCategory) { @Override public ServiceCategory convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + for (ServiceCategory serviceCategory : ServiceCategory.values()) { if (serviceCategory.getType().equals(dbData)) { return serviceCategory; } } - throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + // throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + + // 일치하는 카테고리가 없을 경우 로그를 남기고 기본값 반환 + log.warn("Unknown service category found: '{}'. Using 'OTHER' category instead.", dbData); + return ServiceCategory.OTHER; } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java index 215ae9a..470db1f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -16,8 +16,12 @@ public enum ErrorCode { INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 사용자 이름 또는 비밀번호입니다."), INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 비밀 키입니다."), DELETE_USER_DENIED(HttpStatus.FORBIDDEN, "AUTH-008", "회원 탈퇴가 거부되었습니다."), - ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-009", "권한 정보가 없습니다."), - BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-010", "사용할 수 없는 액세스 토큰입니다."), + CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "AUTH-009","자기 자신을 신고할 수 없습니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-010", "권한 정보가 없습니다."), + BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-011", "사용할 수 없는 액세스 토큰입니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH-012", "해당 신고 내역을 찾을 수 없습니다."), + REPORT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "AUTH-013", "이미 처리된 신고입니다."), + INVALID_REPORT_REQUEST(HttpStatus.BAD_REQUEST, "AUTH-014","잘못된 신고 요청입니다."), // 계정 관련 DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), @@ -40,7 +44,14 @@ 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", "해당 아이디의 게시글을 찾을 수 없습니다"), + + // 답변 + ANSWER_NOT_FOUND(HttpStatus.NOT_FOUND, "ANSWER-001","답변을 찾을 수 없습니다."), + ANSWER_NOT_MATCHED_POST(HttpStatus.BAD_REQUEST, "ANSWER-002","해당 게시글에 속하지 않는 답변입니다."), // 관심사 선택 제한 INTEREST_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "INTEREST-001", "관심사는 최대 6개까지만 등록 가능합니다."), @@ -48,7 +59,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/jwt/JwtTokenParser.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java index a5fc27f..3254604 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java @@ -34,7 +34,7 @@ public Claims parseClaims(String accessToken) { } } - // 토큰에서 이메일 정보 추출 + // 토큰에서 아이디 정보 추출 public String getRealIdFromToken(String accessToken) { Claims claims = Jwts.parserBuilder() .setSigningKey(secretKey) diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java index 8935573..1979f6f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java @@ -41,13 +41,13 @@ public ResponseEntity createAndStoreServiceList() { /** * 공공서비스 상세정보 전체 동기화 (페이징 처리) */ - @PostMapping("/serviceDetailList") + @PostMapping("/serviceDetail") public ResponseEntity createAndStoreServiceDetailList() { validator.validateAndHandleException(() -> { // 전체 상세정보 동기화 (페이징 처리) - publicServiceDataService.syncPublicServiceDetailData(SERVICE_DETAIL_LIST); + publicServiceDataService.syncPublicServiceDetailData(SERVICE_DETAIL); return null; - }, SERVICE_DETAIL_LIST); + }, SERVICE_DETAIL); return ResponseEntity.status(HttpStatus.OK).body("공공서비스 상세정보 데이터 동기화 완료"); } @@ -55,13 +55,13 @@ public ResponseEntity createAndStoreServiceDetailList() { /** * 공공서비스 지원조건 전체 동기화 (페이징 처리) */ - @PostMapping("/supportConditionsList") + @PostMapping("/supportConditions") public ResponseEntity createAndStoreSupportConditionsList() { validator.validateAndHandleException(() -> { // 전체 지원조건 동기화 (페이징 처리) - publicServiceDataService.syncPublicServiceConditionsData(SERVICE_CONDITIONS_LIST); + publicServiceDataService.syncPublicServiceConditionsData(SERVICE_CONDITIONS); return null; - }, SERVICE_CONDITIONS_LIST); + }, SERVICE_CONDITIONS); return ResponseEntity.status(HttpStatus.OK).body("공공서비스 지원조건 데이터 동기화 완료"); } @@ -85,4 +85,18 @@ public ResponseEntity> getServiceListByPage( return ResponseEntity.status(HttpStatus.OK).body(result); } + + // 통합 동기화 + @PostMapping("/sync-all") + public ResponseEntity syncAllPublicServiceData() { + // 순차적으로 실행 + createAndStoreServiceList(); + createAndStoreServiceDetailList(); + createAndStoreSupportConditionsList(); + + // 미사용 데이터 정리 + publicServiceDataService.cleanupObsoleteServices(); + + return ResponseEntity.status(HttpStatus.OK).body("모든 공공서비스 데이터 동기화 완료"); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java index 9e7a019..505fa88 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java @@ -3,10 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.List; @@ -23,13 +20,14 @@ public class PublicServiceConditionsDataDto { private long perPage; @Getter + @Setter @NoArgsConstructor @AllArgsConstructor @Builder @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("JA0101") private String targetGenderMale; @@ -54,46 +52,79 @@ public static class Data { private String incomeLevelVeryHigh; // 중위소득 200% 초과 // Special Group + @JsonProperty("JA0401") private String JA0401; // 다문화가족 + @JsonProperty("JA0402") private String JA0402; // 북한이탈주민 + @JsonProperty("JA0403") private String JA0403; // 한부모가정/조손가정 + @JsonProperty("JA0404") private String JA0404; // 1인가구 + @JsonProperty("JA0328") private String JA0328; // 장애인 + @JsonProperty("JA0329") private String JA0329; // 국가보훈대상자 + @JsonProperty("JA0330") private String JA0330; // 질병/질환자 // Family Type - // private String JA0410; // 해당사항 없음 + @JsonProperty("JA0411") private String JA0411; // 다자녀가구 + @JsonProperty("JA0412") private String JA0412; // 무주택세대 + @JsonProperty("JA0413") private String JA0413; // 신규전입 + @JsonProperty("JA0414") private String JA0414; // 확대가족 // Occupation + @JsonProperty("JA0313") private String JA0313; // 농업인 + @JsonProperty("JA0314") private String JA0314; // 어업인 + @JsonProperty("JA0315") private String JA0315; // 축산업인 + @JsonProperty("JA0316") private String JA0316; // 임업인 + @JsonProperty("JA0317") private String JA0317; // 초등학생 + @JsonProperty("JA0318") private String JA0318; // 중학생 + @JsonProperty("JA0319") private String JA0319; // 고등학생 + @JsonProperty("JA0320") private String JA0320; // 대학생/대학원생 + @JsonProperty("JA0326") private String JA0326; // 근로자/직장인 + @JsonProperty("JA0327") private String JA0327; // 구직자/실업자 // Business Type + @JsonProperty("JA1101") private String JA1101; // 예비 창업자 + @JsonProperty("JA1102") private String JA1102; // 영업중 + @JsonProperty("JA1103") private String JA1103; // 생계곤란/폐업예정자 + @JsonProperty("JA1201") private String JA1201; // 음식업 + @JsonProperty("JA1202") private String JA1202; // 제조업 + @JsonProperty("JA1299") private String JA1299; // 기타업종 + @JsonProperty("JA2101") private String JA2101; // 중소기업 + @JsonProperty("JA2102") private String JA2102; // 사회복지시설 + @JsonProperty("JA2103") private String JA2103; // 기관/단체 + @JsonProperty("JA2201") private String JA2201; // 제조업 + @JsonProperty("JA2202") private String JA2202; // 농업, 임업 및 어업 + @JsonProperty("JA2203") private String JA2203; // 정보통신업 + @JsonProperty("JA2299") private String JA2299; // 기타업종 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java index 25643ff..3eac2e3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java @@ -29,7 +29,7 @@ public class PublicServiceDataDto { @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("서비스명") private String serviceName; diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java index 742ea6c..147f3ca 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java @@ -3,10 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.List; @@ -30,7 +27,7 @@ public class PublicServiceDetailDataDto { @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Data { @JsonProperty("서비스ID") - private long serviceId; + private String serviceId; @JsonProperty("서비스명") private String serviceName; diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java index 8c8a333..08ac767 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mapper/PublicServiceDataMapper.java @@ -30,6 +30,8 @@ public interface PublicServiceDataMapper { PublicService updateFromDetailData(@MappingTarget PublicService publicService, PublicServiceDetailDataDto.Data data); // 공공서비스 지원조건 데이터 매핑 + @Mapping(target = "targetGenderMale", source = "targetGenderMale") + @Mapping(target = "targetGenderFemale", source = "targetGenderFemale") @Mapping(target = "incomeLevel", expression = "java(mapIncomeLevel(data))") PublicService updateFromConditionsData(@MappingTarget PublicService publicService, PublicServiceConditionsDataDto.Data data); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java new file mode 100644 index 0000000..6681011 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/document/PublicData.java @@ -0,0 +1,37 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.document; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "service_info") +public class PublicData { + @Id + private String id; + + private String publicServiceId; + private String serviceName; + private String summaryPurpose; + private String serviceCategory; + private List specialGroup; + private List familyType; + + private List occupations; + private List businessTypes; + + // Support conditions fields + private String targetGenderMale; + private String targetGenderFemale; + private Integer targetAgeStart; + private Integer targetAgeEnd; + private String incomeLevel; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java new file mode 100644 index 0000000..afea3bf --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java @@ -0,0 +1,20 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.repository; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PublicDataMongoRepository extends MongoRepository { + Optional findByPublicServiceId(String publicServiceId); + List findAllByPublicServiceId(String publicServiceId); + List findAllByPublicServiceIdIn(Collection publicServiceIds); + + @Query(value = "{}", fields = "{ 'publicServiceId' : 1 }") + List findAllPublicServiceIds(); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java new file mode 100644 index 0000000..8cd794a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -0,0 +1,236 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.repository.PublicDataMongoRepository; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.bson.Document; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublicDataMongoService { + + private final PublicDataMongoRepository mongoRepository; + private final MongoTemplate mongoTemplate; + + /** + * 단일 공공서비스 엔티티를 MongoDB에 저장 + */ + public PublicData saveToMongo(PublicService publicService) { + PublicData document = convertToDocument(publicService); + return mongoRepository.save(document); + } + + /** + * 공공서비스 엔티티를 MongoDB 문서로 변환 + */ + private PublicData convertToDocument(PublicService publicService) { + // 특수 그룹 정보 추출 + List specialGroups = publicService.getSpecialGroups().stream() + .map(sg -> sg.getSpecialGroupEnum().getType()) + .collect(Collectors.toList()); + + // 가족 유형 정보 추출 + List familyTypes = publicService.getFamilyTypes().stream() + .map(ft -> ft.getFamilyTypeEnum().getType()) + .collect(Collectors.toList()); + + // 직업 정보 추출 + List occupations = publicService.getOccupations().stream() + .map(occ -> occ.getOccupationEnum().getType()) + .collect(Collectors.toList()); + + // 사업체 유형 정보 추출 + List businessTypes = publicService.getBusinessTypes().stream() + .map(bt -> bt.getBusinessTypeEnum().getType()) + .collect(Collectors.toList()); + + // MongoDB 문서 생성 및 반환 + return PublicData.builder() + .publicServiceId(publicService.getId()) + .serviceName(publicService.getServiceName()) + .summaryPurpose(publicService.getSummaryPurpose()) + .serviceCategory(publicService.getServiceCategory().getType()) + .specialGroup(specialGroups) + .familyType(familyTypes) + .occupations(occupations) + .businessTypes(businessTypes) + .targetGenderMale(publicService.getTargetGenderMale()) + .targetGenderFemale(publicService.getTargetGenderFemale()) + .targetAgeStart(publicService.getTargetAgeStart()) + .targetAgeEnd(publicService.getTargetAgeEnd()) + .incomeLevel(publicService.getIncomeLevel()) + .build(); + } + + /** + * 서비스 ID로 문서 조회 + */ + public Optional findByPublicServiceId(String publicServiceId) { + return mongoRepository.findByPublicServiceId(publicServiceId); + } + + /** + * 기존 문서 업데이트 또는 새 문서 생성 + */ + public PublicData updateOrCreateDocument(PublicService publicService) { + Optional existingDoc = mongoRepository.findByPublicServiceId(publicService.getId()); + + if (existingDoc.isPresent()) { + // 기존 문서의 ID 유지하면서 데이터 업데이트 + PublicData newData = convertToDocument(publicService); + newData.setId(existingDoc.get().getId()); + return mongoRepository.save(newData); + } else { + // 새 문서 생성 + return saveToMongo(publicService); + } + } + + /** + * 여러 서비스 문서 업데이트 또는 생성 + */ + public void updateOrCreateBulkDocuments(List services) { + // 모든 service ID 목록 + List serviceIds = services.stream() + .map(PublicService::getId) + .collect(Collectors.toList()); + + // publicServiceId로 기존 문서 조회 (중요: findAllByPublicServiceId를 사용) + Map existingDocsMap = mongoRepository.findAllByPublicServiceIdIn(serviceIds).stream() + .collect(Collectors.toMap( + PublicData::getPublicServiceId, + doc -> doc, + (a, b) -> a // 중복 시 첫 번째 문서 유지 + )); + + // 처리할 문서 준비 + List docsToSave = services.stream() + .map(service -> { + PublicData doc = convertToDocument(service); + if (existingDocsMap.containsKey(service.getId())) { + // 기존 문서의 ID 유지 + doc.setId(existingDocsMap.get(service.getId()).getId()); + } + return doc; + }) + .collect(Collectors.toList()); + + // 저장 + mongoRepository.saveAll(docsToSave); + } + + // 첫 실행 시에만 중복 제거 및 인덱스 생성 + @PostConstruct + public void ensureIndexes() { + try { + // 1. 기존 인덱스 확인 + boolean hasUniqueIndex = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName())) { + hasUniqueIndex = indexInfo.isUnique(); + break; + } + } + + // 2. 유니크 인덱스가 없는 경우만 처리 + if (!hasUniqueIndex) { + // 2.1 일반 인덱스 존재 여부 확인 + boolean hasNonUniqueIndex = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName()) && !indexInfo.isUnique()) { + hasNonUniqueIndex = true; + break; + } + } + + // 2.2 일반 인덱스가 있다면 삭제 + if (hasNonUniqueIndex) { + mongoTemplate.indexOps("service_info").dropIndex("publicServiceId_1"); + log.info("기존 비유니크 인덱스 삭제: publicServiceId_1"); + } + + // 2.3 최적화된 중복 제거 실행 + deduplicateMongoDocuments(); + + // 2.4 유니크 인덱스 생성 + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } else { + log.info("MongoDB 유니크 인덱스 이미 존재함: publicServiceId_1"); + } + } catch (Exception e) { + log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); + // 인덱스 생성 실패해도 애플리케이션은 시작되도록 함 + } + } + + @Transactional + public void deduplicateMongoDocuments() { + log.info("MongoDB 문서 중복 제거 시작 (최적화 버전)"); + + // 모든 publicServiceId와 해당 문서 ID를 그룹화하여 한 번에 조회 + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation( + Aggregation.group("publicServiceId") + .first("_id").as("firstId") + .push("_id").as("allIds") + .count().as("count") + ), + "service_info", + Document.class + ); + + int totalProcessed = 0; + int totalRemoved = 0; + + for (Document doc : results.getMappedResults()) { + int count = doc.getInteger("count"); + + // 중복이 있는 경우에만 처리 + if (count > 1) { + String publicServiceId = doc.getString("_id"); + List allIds = (List) doc.get("allIds"); + Object firstId = doc.get("firstId"); + + // 첫 번째 문서를 제외한 나머지 문서 삭제 + for (int i = 0; i < allIds.size(); i++) { + Object currentId = allIds.get(i); + if (!currentId.equals(firstId)) { + mongoTemplate.remove(Query.query(Criteria.where("_id").is(currentId)), "service_info"); + totalRemoved++; + } + } + } + + totalProcessed++; + if (totalProcessed % 1000 == 0) { + log.info("중복 제거 진행 중: {}/{} 그룹 처리, {}개 제거됨", + totalProcessed, results.getMappedResults().size(), totalRemoved); + } + } + + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 그룹 중 {}개 중복 제거됨", + totalProcessed, totalRemoved); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/schedule/PublicServiceDataScheduler.java index 1499136..2ae7aa7 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 @@ -2,6 +2,7 @@ import com.hyetaekon.hyetaekon.common.publicdata.service.PublicServiceDataService; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicDataPath; +import com.hyetaekon.hyetaekon.publicservice.util.ServiceCacheManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -13,15 +14,16 @@ public class PublicServiceDataScheduler { private final PublicServiceDataService publicServiceDataService; + private final ServiceCacheManager serviceCacheManager; /** - * 매일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 + * 매주 월요일 새벽 2시에 공공서비스 데이터 전체 동기화 실행 * 1. 서비스 목록 동기화 * 2. 서비스 상세정보 동기화 * 3. 서비스 지원조건 동기화 * 4. 더 이상 사용되지 않는 데이터 정리 */ - @Scheduled(cron = "0 0 2 * * ?") + @Scheduled(cron = "0 0 2 ? * MON") public void syncAllPublicServiceData() { log.info("공공서비스 데이터 전체 동기화 스케줄러 시작"); @@ -32,17 +34,20 @@ 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("미사용 공공서비스 데이터 정리 시작"); int deletedCount = publicServiceDataService.cleanupObsoleteServices(); log.info("미사용 공공서비스 데이터 {}건 삭제 완료", deletedCount); + // 5. 동기화 완료 후 캐시 초기화 + serviceCacheManager.clearAllServiceCaches(); + log.info("공공서비스 데이터 전체 동기화 완료"); } catch (Exception e) { log.error("공공서비스 데이터 동기화 중 오류 발생", e); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java index 6d31222..e67841f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java @@ -33,24 +33,31 @@ void updateSpecialGroups(PublicService publicService, PublicServiceConditionsDat List specialGroups = new ArrayList<>(); if ("Y".equals(data.getJA0401())) { + log.debug("다문화가족 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_MULTI_CULTURAL)); } if ("Y".equals(data.getJA0402())) { + log.debug("북한이탈주민 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_NORTH_KOREAN_DEFECTOR)); } if ("Y".equals(data.getJA0403())) { + log.debug("한부모가정/조손가정 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_SINGLE_PARENT_FAMILY)); } if ("Y".equals(data.getJA0404())) { + log.debug("1인가구 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_SINGLE_MEMBER_HOUSEHOLD)); } if ("Y".equals(data.getJA0328())) { + log.debug("장애인 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_DISABLED)); } if ("Y".equals(data.getJA0329())) { + log.debug("국가보훈대상자 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_NATIONAL_MERIT_RECIPIENT)); } if ("Y".equals(data.getJA0330())) { + log.debug("질병/질환자 특수 그룹 추가"); specialGroups.add(createSpecialGroup(publicService, SpecialGroupEnum.IS_CHRONIC_ILLNESS)); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java index f6d8315..fd68168 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java @@ -4,15 +4,18 @@ import com.hyetaekon.hyetaekon.common.publicdata.dto.PublicServiceDataDto; import com.hyetaekon.hyetaekon.common.publicdata.dto.PublicServiceDetailDataDto; import com.hyetaekon.hyetaekon.common.publicdata.mapper.PublicServiceDataMapper; +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicDataPath; import com.hyetaekon.hyetaekon.common.publicdata.util.PublicServiceDataValidate; import com.hyetaekon.hyetaekon.publicservice.entity.*; import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.web.client.RestTemplate; @@ -28,7 +31,7 @@ @Service @RequiredArgsConstructor public class PublicServiceDataServiceImpl implements PublicServiceDataService { - + private final PublicDataMongoService publicDataMongoService; private final PublicServiceRepository publicServiceRepository; private final PublicServiceDataMapper publicServiceDataMapper; private final PublicServiceDataProviderService publicServiceDataProviderService; @@ -36,7 +39,7 @@ public class PublicServiceDataServiceImpl implements PublicServiceDataService { private final PublicServiceDataValidate validator; private static final int DEFAULT_PAGE_SIZE = 1000; // 기본 페이지 크기를 1000으로 설정 - private final Set currentServiceIds = ConcurrentHashMap.newKeySet(); // 현재 동기화된 서비스 ID 저장 + private final Set currentServiceIds = ConcurrentHashMap.newKeySet(); // 현재 동기화된 서비스 ID 저장 /** * 공공서비스 목록 데이터 호출 (페이징 처리) @@ -145,7 +148,7 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { /** * 공통 데이터 동기화 메서드 - 중복 코드 제거를 위한 템플릿 메서드 */ - private long syncDataWithPaging( + private void syncDataWithPaging( PublicDataPath apiPath, BiFunction> fetcher, // 데이터 조회 함수 Function> dataExtractor, // DTO에서 데이터 추출 함수 @@ -204,7 +207,6 @@ private long syncDataWithPaging( } log.info("{} 전체 동기화 완료: 총 {}건", operationName, totalProcessed); - return totalProcessed; } /** @@ -237,14 +239,16 @@ public List upsertServiceData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 목록 데이터 {}건 저장 완료", validatedData.size()); @@ -260,12 +264,12 @@ public List upsertServiceDetailData(List entitiesToSave = new ArrayList<>(); // 서비스 ID 목록 생성 - Set serviceIds = dataList.stream() + Set serviceIds = dataList.stream() .map(PublicServiceDetailDataDto.Data::getServiceId) .collect(Collectors.toSet()); // 서비스 ID로 한 번에 조회 (N+1 문제 방지) - Map serviceMap = publicServiceRepository.findAllById(serviceIds) + Map serviceMap = publicServiceRepository.findAllById(serviceIds) .stream() .collect(Collectors.toMap(PublicService::getId, service -> service)); @@ -289,14 +293,16 @@ public List upsertServiceDetailData(List= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 상세정보 데이터 {}건 저장 완료", validatedData.size()); @@ -306,18 +312,18 @@ public List upsertServiceDetailData(List upsertSupportConditionsData(List dataList) { List validatedData = new ArrayList<>(); List entitiesToSave = new ArrayList<>(); // 서비스 ID 목록 생성 - Set serviceIds = dataList.stream() + Set serviceIds = dataList.stream() .map(PublicServiceConditionsDataDto.Data::getServiceId) .collect(Collectors.toSet()); // 서비스 ID로 한 번에 조회 (N+1 문제 방지) - Map serviceMap = publicServiceRepository.findAllById(serviceIds) + Map serviceMap = publicServiceRepository.findAllById(serviceIds) .stream() .collect(Collectors.toMap(PublicService::getId, service -> service)); @@ -345,14 +351,16 @@ public List upsertSupportConditionsData(Lis // 배치 처리 최적화: 1000개 단위로 저장 if (entitiesToSave.size() >= 1000) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { - publicServiceRepository.saveAll(entitiesToSave); + List savedEntities = publicServiceRepository.saveAll(entitiesToSave); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 지원조건 데이터 {}건 저장 완료", validatedData.size()); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java index 2cfd539..633c97a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicDataPath.java @@ -7,8 +7,8 @@ @Getter public enum PublicDataPath { SERVICE_LIST("/serviceList"), - SERVICE_DETAIL_LIST("/serviceDetailList"), - SERVICE_CONDITIONS_LIST("/supportConditionsList"); + SERVICE_DETAIL("/serviceDetail"), + SERVICE_CONDITIONS("/supportConditions"); private final String path; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java index a1fda6b..c177ea5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java @@ -60,14 +60,19 @@ public boolean validatePublicServiceData(PublicServiceDataDto.Data data) { return true; } + // PublicServiceDataValidate.java 수정 public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data data) { - if (data.getServicePurpose() == null || data.getServicePurpose().isEmpty() || - data.getSupportTarget() == null || data.getSupportTarget().isEmpty() || - data.getSupportDetail() == null || data.getSupportDetail().isEmpty() || - data.getSupportType() == null || data.getSupportType().isEmpty() || - data.getApplicationMethod() == null || data.getApplicationMethod().isEmpty() || - data.getApplicationDeadline() == null || data.getApplicationDeadline().isEmpty() || - data.getGoverningAgency() == null || data.getGoverningAgency().isEmpty()) { + // 필수 필드 리스트를 먼저 확인 + boolean isValid = data.getServicePurpose() != null && !data.getServicePurpose().isEmpty() && + data.getSupportTarget() != null && !data.getSupportTarget().isEmpty() && + data.getSupportDetail() != null && !data.getSupportDetail().isEmpty() && + data.getSupportType() != null && !data.getSupportType().isEmpty() && + data.getApplicationMethod() != null && !data.getApplicationMethod().isEmpty() && + data.getApplicationDeadline() != null && !data.getApplicationDeadline().isEmpty() && + data.getGoverningAgency() != null && !data.getGoverningAgency().isEmpty() && + data.getContactInfo() != null && !data.getContactInfo().isEmpty(); + + if (!isValid) { log.warn("⚠️ 공공 서비스 상세내용 ID {}에 필수 데이터가 누락되었습니다.", data.getServiceId()); return false; } @@ -75,28 +80,28 @@ public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data d } public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDto.Data data) { - // 성별 조건 확인 (필수) + // 성별 조건 확인 boolean hasGenderCondition = "Y".equals(data.getTargetGenderMale()) || "Y".equals(data.getTargetGenderFemale()); - // 성별 정보가 없으면 유효하지 않은 데이터로 간주 + // 성별 조건이 없는 경우, 기본값으로 모두 Y 설정 if (!hasGenderCondition) { - log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 지원 조건이 없습니다.", data.getServiceId()); - return false; + log.info("ℹ️ 공공 서비스 지원조건 ID {}에 성별 지원 조건이 없습니다. 기본값으로 남성/여성 모두 Y로 설정합니다.", data.getServiceId()); + data.setTargetGenderMale("Y"); + data.setTargetGenderFemale("Y"); + // hasGenderCondition = true; } - // 특수 그룹 조건 확인 + // 다른 조건들 검사 (경고만 로깅하고 실제로는 모든 데이터 허용) boolean hasSpecialGroupCondition = "Y".equals(data.getJA0401()) || "Y".equals(data.getJA0402()) || "Y".equals(data.getJA0403()) || "Y".equals(data.getJA0404()) || "Y".equals(data.getJA0328()) || "Y".equals(data.getJA0329()) || "Y".equals(data.getJA0330()); - // 가족 유형 조건 확인 boolean hasFamilyTypeCondition = "Y".equals(data.getJA0411()) || "Y".equals(data.getJA0412()) || "Y".equals(data.getJA0413()) || "Y".equals(data.getJA0414()); - // 직업 유형 조건 확인 boolean hasOccupationCondition = "Y".equals(data.getJA0313()) || "Y".equals(data.getJA0314()) || "Y".equals(data.getJA0315()) || "Y".equals(data.getJA0316()) || @@ -104,7 +109,6 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt "Y".equals(data.getJA0319()) || "Y".equals(data.getJA0320()) || "Y".equals(data.getJA0326()) || "Y".equals(data.getJA0327()); - // 사업체 유형 조건 확인 boolean hasBusinessTypeCondition = "Y".equals(data.getJA1101()) || "Y".equals(data.getJA1102()) || "Y".equals(data.getJA1103()) || "Y".equals(data.getJA1201()) || @@ -114,13 +118,13 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt "Y".equals(data.getJA2202()) || "Y".equals(data.getJA2203()) || "Y".equals(data.getJA2299()); - // 성별 외에 다른 지원 조건이 하나라도 있어야 함 if (!(hasSpecialGroupCondition || hasFamilyTypeCondition || hasOccupationCondition || hasBusinessTypeCondition)) { log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 외 다른 지원 조건이 없습니다.", data.getServiceId()); - return false; + // 경고만 로깅하고 데이터는 허용 } + // 항상 true 반환하여 모든 데이터 허용 return true; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java index 10e924c..eff46bc 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java @@ -4,6 +4,7 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; +import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -12,6 +13,7 @@ @EntityListeners(AuditingEntityListener.class) @Getter +@Setter @MappedSuperclass public class BaseEntity { @@ -22,4 +24,5 @@ public class BaseEntity { @LastModifiedDate @Column(name = "modified_at", nullable = false, columnDefinition = "DATETIME(0)") private LocalDateTime modifiedAt; + } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieProperties.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieProperties.java new file mode 100644 index 0000000..827fd50 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieProperties.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.common.util; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.cookie") +@Getter +@Setter +public class CookieProperties { + private boolean secure; + private String sameSite; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java index e3b2867..1a81bfa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java @@ -5,18 +5,26 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; import org.springframework.web.util.WebUtils; import java.util.Optional; +@Component public class CookieUtil { + + private final CookieProperties cookieProperties; + + public CookieUtil(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } // 쿠키 설정 - public static void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { + public void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) .httpOnly(true) - .secure(true) + .secure(cookieProperties.isSecure()) .path("/") - .sameSite("None") + .sameSite(cookieProperties.getSameSite()) .maxAge(maxAge) .build(); @@ -24,12 +32,12 @@ public static void setCookie(HttpServletResponse response, String name, String v } // 쿠키 삭제 - public static void deleteCookie(HttpServletResponse response, String name) { + public void deleteCookie(HttpServletResponse response, String name) { ResponseCookie cookie = ResponseCookie.from(name, null) .httpOnly(true) - .secure(true) + .secure(cookieProperties.isSecure()) .path("/") - .sameSite("None") + .sameSite(cookieProperties.getSameSite()) .maxAge(0) // 즉시 만료 .build(); @@ -37,7 +45,7 @@ public static void deleteCookie(HttpServletResponse response, String name) { } // 특정 쿠키 값 가져오기 - public static Optional getCookieValue(HttpServletRequest request, String name) { + public Optional getCookieValue(HttpServletRequest request, String name) { Cookie cookie = WebUtils.getCookie(request, name); return cookie != null ? Optional.of(cookie.getValue()) : Optional.empty(); } 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..019266c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -1,13 +1,20 @@ package com.hyetaekon.hyetaekon.post.controller; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.post.dto.*; +import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.service.PostService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; +@Slf4j @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -15,40 +22,58 @@ public class PostController { private final PostService postService; - // ✅ 게시글 생성 - @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); + // PostType에 해당하는 게시글 목록 조회 + @GetMapping("/type") + public ResponseEntity> getPosts( + @RequestParam(required = false, defaultValue = "ALL") 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(keyword, sortBy, direction, pageable)); + } else { + return ResponseEntity.ok(postService.getPostsByType(type, keyword, sortBy, direction, pageable)); + } } - // ✅ 특정 게시글 조회 + // 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())); } - // ✅ 특정 카테고리의 게시글 조회 - @GetMapping("/category/{categoryId}") - public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { - return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); - } + // ✅ 게시글 생성 + @PostMapping + public ResponseEntity createPost( + @ModelAttribute PostCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { - // ✅ 모든 게시글 조회 - @GetMapping - public ResponseEntity> getAllPosts() { - return ResponseEntity.ok(postService.getAllPosts()); + PostDetailResponseDto dto = postService.createPost(requestDto, userDetails.getId()); + return ResponseEntity.ok(dto); } - // ✅ 게시글 수정 + + // ✅ 게시글 수정 - 본인 @PutMapping("/{postId}") - public ResponseEntity updatePost(@PathVariable Long postId, @RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.updatePost(postId, postDto)); + public ResponseEntity updatePost( + @PathVariable Long postId, + @ModelAttribute PostUpdateRequestDto updateDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.updatePost(postId, updateDto, userDetails.getId())); } - // ✅ 게시글 삭제 (soft delete 방식 사용 가능) + // ✅ 게시글 삭제 (soft delete 방식 사용 가능) - 본인 혹은 관리자 @DeleteMapping("/{postId}") - public ResponseEntity deletePost(@PathVariable Long postId) { - postService.deletePost(postId); + public ResponseEntity deletePost( + @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.deletePost(postId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java new file mode 100644 index 0000000..8d5cf99 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MyPostListResponseDto { + private Long postId; + private String title; + private String content; + private String nickName; // 작성자 닉네임 + private LocalDateTime createdAt; + private int recommendCnt; + private int commentCnt; + private int viewCnt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java new file mode 100644 index 0000000..ad653d6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -0,0 +1,22 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostCreateRequestDto { + private String title; + private String content; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List images; // 이미지 파일 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java similarity index 60% rename from src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java rename to src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index 61cea56..6335aa6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -1,27 +1,29 @@ package com.hyetaekon.hyetaekon.post.dto; -import lombok.Getter; -import lombok.Setter; + +import lombok.*; + import java.time.LocalDateTime; import java.util.List; @Getter @Setter -public class PostDto { - private Long id; +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDetailResponseDto { + private Long postId; + private String nickName; // 작성자 닉네임 private Long userId; - private Long 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 int viewCnt; private String urlTitle; private String urlPath; private String tags; - private Long categoryId; // 추가됨 private List imageUrls; // ✅ 이미지 URL 리스트 추가 + private boolean recommended; // 현재 로그인한 사용자의 추천 여부 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java new file mode 100644 index 0000000..43067c6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; + + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostListResponseDto { + private Long postId; + private String title; + private String nickName; // 작성자 닉네임 + private Long userId; + private LocalDateTime createdAt; + private int viewCnt; + private String postType; + private int recommendCnt; +} 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..a1b6b28 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostUpdateRequestDto { + private String title; + private String content; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + + private List keepImageIds; // 유지할 기존 이미지 ID + private List newImages; // 새로 추가할 이미지 +} 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..26d1f78 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -1,52 +1,68 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @Getter @Setter -@Table(name = "posts") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "post") +@EntityListeners(AuditingEntityListener.class) public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 게시글 ID - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + @Column(columnDefinition = "VARCHAR(40) CHARACTER SET utf8mb4", nullable = false) // ✅ 제목 20자 제한 private String title; - @Column(length = 500, nullable = false) // ✅ 내용 500자 제한 + @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 private String content; - private LocalDateTime createdAt = LocalDateTime.now(); - - private LocalDateTime deletedAt; + @Builder.Default + @Column(name = "recommend_cnt") + private int recommendCnt = 0; // 추천수 - private int recommendCnt; // 추천수 + @Builder.Default + @Column(name = "view_count") + private int viewCnt = 0; // 조회수 - private int viewCount; // 조회수 + // TODO: 댓글 생성/수정 시 업데이트 + @Builder.Default + @Column(name = "comment_cnt") + private int commentCnt = 0; // 댓글수 - @Column(name = "post_type") + @Column(name = "post_type", nullable = false) @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) private PostType postType; private String serviceUrl; - @Column(length = 12) // ✅ 관련 링크 제목 12자 제한 + @Column(columnDefinition = "VARCHAR(12) CHARACTER SET utf8mb4") // ✅ url제목 12자 제한 private String urlTitle; private String urlPath; @@ -57,6 +73,64 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "suspend_at") + private LocalDateTime suspendAt; + + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private List postImages; // ✅ 게시글 이미지와 연결 + private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + + @Builder.Default + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + 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++; + } + + public void decrementCommentCnt() { + this.commentCnt = Math.max(0, this.commentCnt - 1); + } + + // 삭제 처리 + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + // 정지 처리 + public void suspend() { + this.suspendAt = LocalDateTime.now(); + } + + public String getDisplayContent() { + if (this.deletedAt != null) { + return "삭제된 게시글입니다."; + } else if (this.suspendAt != null) { + return "관리자에 의해 삭제된 게시글입니다."; + } + return content; + } + } 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..b8ae7d1 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -1,22 +1,33 @@ package com.hyetaekon.hyetaekon.post.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; + +import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class PostImage { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") private Post post; @Column(name = "image_url", length = 255) private String imageUrl; + + private LocalDateTime deletedAt; + + // Soft delete 처리 메소드 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java index db9cb66..905a890 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,45 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum PostType { - QUESTION, // 질문 게시판 - FREE, // 자유 게시판 - GREETING // 인사 게시판 + ALL("전체"), + QUESTION("질문"), + FREE("자유"), + GREETING("인사"); + + @JsonValue + private final String koreanName; + + /** + * 한글 이름으로 PostType을 찾습니다. + */ + public static PostType fromKoreanName(String koreanName) { + for (PostType type : values()) { + if (type.getKoreanName().equals(koreanName)) { + return type; + } + } + // 일치하는 이름이 없거나 null인 경우 기본값으로 ALL 반환 + return ALL; + } + + /** + * 클라이언트에서 영문 타입 코드로 전송된 PostType을 찾습니다. + */ + public static PostType fromString(String typeCode) { + if (typeCode == null || typeCode.trim().isEmpty()) { + return ALL; // 기본값 + } + + try { + return PostType.valueOf(typeCode.toUpperCase()); + } catch (IllegalArgumentException e) { + return ALL; + } + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java new file mode 100644 index 0000000..d795d9c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java @@ -0,0 +1,50 @@ +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()); + } + + // List → List 변환 + default List toImageUrls(List postImages) { + if (postImages == null || postImages.isEmpty()) { + return Collections.emptyList(); + } + + return postImages.stream() + .filter(img -> img.getDeletedAt() == null) + .map(PostImage::getImageUrl) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java index 908c493..6d5d86d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -1,62 +1,46 @@ package com.hyetaekon.hyetaekon.post.mapper; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.entity.PostImage; -import com.hyetaekon.hyetaekon.post.entity.PostType; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; -import com.hyetaekon.hyetaekon.user.entity.User; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; +import org.mapstruct.*; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE, uses = PostImageMapper.class) 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); - } - - return post; - } - - default List mapPostImages(Post post) { - if (post.getPostImages() == null) return null; - return post.getPostImages().stream() - .map(PostImage::getImageUrl) - .collect(Collectors.toList()); - } + // ✅ 게시글 목록용 DTO 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + @Mapping(source = "recommendCnt", target = "recommendCnt") + @Mapping(source = "user.id", target = "userId") // 🔥 추가 + PostListResponseDto toPostListDto(Post post); + + // ✅ 마이페이지용 게시글 DTO + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(target = "content", expression = "java(post.getDisplayContent())") + MyPostListResponseDto toMyPostListDto(Post post); + + // ✅ 게시글 생성 시 DTO → Entity 변환 + Post toEntity(PostCreateRequestDto createDto); + + // ✅ 게시글 수정 시 일부 값만 업데이트 (null 무시) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); + + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.id", target = "userId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(target = "content", expression = "java(post.getDisplayContent())") + @Mapping(source = "postType.koreanName", target = "postType") + @Mapping(target = "recommended", constant = "false") + @Mapping(source = "postImages", target = "imageUrls") + PostDetailResponseDto toPostDetailDto(Post post); + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java new file mode 100644 index 0000000..7994337 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.post.repository; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostImageRepository extends JpaRepository { + List findByPostAndDeletedAtIsNull(Post post); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 2734c4e..4f1fcf8 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,63 @@ 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.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; 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_IdAndPostTypeAndDeletedAtIsNull(Long userId, PostType postType); + + // 특정 사용자가 작성한 게시글 조회 + @EntityGraph(attributePaths = {"user"}) + @Query("SELECT p FROM Post p " + + "WHERE p.user.id = :userId " + + "AND p.deletedAt IS NULL " + + "ORDER BY p.createdAt DESC") + Page findMyPostsOptimized(@Param("userId") Long userId, Pageable pageable); + + // 특정 사용자가 추천한 게시글 조회 + @Query("SELECT DISTINCT p FROM Post p " + + "JOIN p.recommends r " + + "WHERE r.user.id = :userId " + + "AND p.deletedAt IS NULL " + + "ORDER BY r.createdAt DESC") + Page findRecommendedPostsOptimized(@Param("userId") Long userId, Pageable pageable); + + // 제목 검색 + 삭제되지 않은 게시글 + Page findByTitleContainingAndDeletedAtIsNull(String keyword, Pageable pageable); + + // 제목 검색 + 특정 타입 + 삭제되지 않은 게시글 + Page findByPostTypeAndTitleContainingAndDeletedAtIsNull(PostType postType, String keyword, Pageable pageable); + + // 삭제된 게시글만 조회 (관리자용) + Page findByDeletedAtIsNotNull(Pageable pageable); + + // 정지된 게시글만 조회 (관리자용) + Page findBySuspendAtIsNotNull(Pageable pageable); + + + // 통계용 카운팅 메서드들 + long count(); + long countByDeletedAtIsNotNull(); + long countBySuspendAtIsNotNull(); - 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 ae576b8..8ae0181 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,76 +1,346 @@ package com.hyetaekon.hyetaekon.post.service; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.s3bucket.service.S3BucketService; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; import com.hyetaekon.hyetaekon.post.entity.PostType; +import com.hyetaekon.hyetaekon.post.mapper.PostImageMapper; import com.hyetaekon.hyetaekon.post.mapper.PostMapper; +import com.hyetaekon.hyetaekon.post.repository.PostImageRepository; import com.hyetaekon.hyetaekon.post.repository.PostRepository; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; +import com.hyetaekon.hyetaekon.user.entity.PointActionType; import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import com.hyetaekon.hyetaekon.user.service.UserPointService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; +import java.util.Set; + +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; +@Slf4j @Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; + private final PostImageRepository postImageRepository; + private final UserRepository userRepository; + private final RecommendRepository recommendRepository; private final PostMapper postMapper; + private final PostImageMapper postImageMapper; + private final S3BucketService s3BucketService; + private final UserPointService userPointService; + + // 이미지 업로드 제한 설정 + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_FILES_COUNT = 5; // 최대 5개 이미지 + private static final Set ALLOWED_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/gif" + ); + + /** + * 전체 게시글 목록 조회 (제목 검색 + 정렬) + */ + public Page getAllPosts(String keyword, String sortBy, String direction, Pageable pageable) { + Pageable sortedPageable = createSortedPageable(pageable, sortBy, direction); - public List getAllPosts() { - return postRepository.findAll() - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + if (keyword != null && !keyword.trim().isEmpty()) { + return postRepository.findByTitleContainingAndDeletedAtIsNull(keyword, sortedPageable) + .map(postMapper::toPostListDto); + } else { + return postRepository.findByDeletedAtIsNull(sortedPageable) + .map(postMapper::toPostListDto); + } } - public PostDto getPostById(Long id) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - return postMapper.toDto(post); + /** + * 특정 타입 게시글 목록 조회 (제목 검색 + 정렬) + */ + public Page getPostsByType(PostType postType, String keyword, String sortBy, String direction, Pageable pageable) { + Pageable sortedPageable = createSortedPageable(pageable, sortBy, direction); + + if (keyword != null && !keyword.trim().isEmpty()) { + return postRepository.findByPostTypeAndTitleContainingAndDeletedAtIsNull(postType, keyword, sortedPageable) + .map(postMapper::toPostListDto); + } else { + return postRepository.findByPostTypeAndDeletedAtIsNull(postType, sortedPageable) + .map(postMapper::toPostListDto); + } } - public List getPostsByCategoryId(Long categoryId) { // 추가됨 - return postRepository.findByCategoryId(categoryId) - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 정렬 기준을 적용한 Pageable 생성 + */ + private Pageable createSortedPageable(Pageable pageable, String sortBy, String direction) { + Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); } - public PostDto createPost(PostDto postDto) { - Post post = postMapper.toEntity(postDto); - Post savedPost = postRepository.save(post); - return postMapper.toDto(savedPost); + /** + * 특정 게시글 상세 조회(로그인 시) + */ + @Transactional + public PostDetailResponseDto getPostById(Long postId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 조회수 증가 + post.incrementViewCnt(); + + // 사용자의 추천 여부 확인 + boolean recommended = recommendRepository.existsByUserIdAndPostId(userId, postId); + + // DTO 변환 및 추천 여부 설정 + PostDetailResponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); + + return responseDto; } - public PostDto updatePost(Long id, PostDto postDto) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - User user = new User(); - user.setId(postDto.getUserId()); + /** + * 게시글 생성(로그인 시) + */ + @Transactional + public PostDetailResponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + PostType postType = PostType.fromString(requestDto.getPostType()); + log.info("Received postType: '{}'", requestDto.getPostType()); + + // 포인트 부여 로직을 게시글 저장 전에 수행 + boolean isFirstGreetingPost = false; + try { + if (postType == PostType.GREETING) { + // 첫 인사 게시글 여부 확인 (게시글 저장 전에 체크) + isFirstGreetingPost = !postRepository.existsByUser_IdAndPostTypeAndDeletedAtIsNull(userId, PostType.GREETING); + log.info("인사 게시글 작성 체크 - 사용자 ID: {}, 첫 인사 게시글 여부: {}", userId, isFirstGreetingPost); + } + } catch (Exception e) { + log.error("포인트 부여 체크 중 오류 발생 - 사용자 ID: {}", userId, e); + } + + // 게시글 저장 + Post post = postMapper.toEntity(requestDto); post.setUser(user); + post.setPostType(postType); + + Post savedPost = postRepository.save(post); + + // 이미지 처리 + if (requestDto.getImages() != null && !requestDto.getImages().isEmpty()) { + List postImages = processPostImages(requestDto.getImages(), savedPost); + if (!postImages.isEmpty()) { + postImageRepository.saveAll(postImages); + savedPost.setPostImages(postImages); + } + } + + // 게시글 저장 후 포인트 부여 + try { + if (postType == PostType.GREETING) { + if (isFirstGreetingPost) { + // 첫 인사 게시글인 경우 100점 + userPointService.addPointForAction(userId, PointActionType.FIRST_GREETING_POST_CREATION); + log.info("첫 인사 게시글 작성 완료 - 사용자 ID: {}, 부여 포인트: 100점", userId); + } else { + // 추가 인사 게시글인 경우 20점 + userPointService.addPointForAction(userId, PointActionType.POST_CREATION); + log.info("추가 인사 게시글 작성 완료 - 사용자 ID: {}, 부여 포인트: 20점", userId); + } + } else { + // 일반 게시글인 경우 20점 + userPointService.addPointForAction(userId, PointActionType.POST_CREATION); + log.info("일반 게시글 작성 완료 - 사용자 ID: {}, 부여 포인트: 20점", userId); + } + } catch (Exception e) { + log.error("포인트 부여 중 오류 발생 - 사용자 ID: {}, 게시글 ID: {}", userId, savedPost.getId(), e); + // 포인트 부여 실패해도 게시글 생성은 성공으로 처리 + } + + return postMapper.toPostDetailDto(savedPost); + } + + + /** + * 게시글 수정 (본인만 가능) + */ + @Transactional + public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 작성자 확인 + if (!post.getUser().getId().equals(userId)) { + throw new AccessDeniedException("게시글 수정 권한이 없습니다"); + } + + // 영문 타입 코드로 PostType 찾기 + if (updateDto.getPostType() != null) { + PostType postType = PostType.fromString(updateDto.getPostType()); + post.setPostType(postType); + } + + // 기본 정보 업데이트(title, content, urlTitle, urlPath, tags) + postMapper.updatePostFromDto(updateDto, post); + + // 이미지 업데이트 처리 + if (hasImageChanges(updateDto)) { // null-safe + updatePostImages(post, updateDto.getKeepImageIds(), updateDto.getNewImages()); + } + + // 업데이트 후 최신 상태로 다시 조회 + Post updatedPost = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다")); - PublicService publicService = new PublicService(); - publicService.setId(postDto.getPublicServiceId()); - post.setPublicService(publicService); - - post.setTitle(postDto.getTitle()); - post.setContent(postDto.getContent()); - post.setPostType(PostType.valueOf(postDto.getPostType())); - post.setDeletedAt(postDto.getDeletedAt()); - post.setServiceUrl(postDto.getServiceUrl()); - post.setRecommendCnt(postDto.getRecommendCnt()); - post.setViewCount(postDto.getViewCount()); - post.setUrlTitle(postDto.getUrlTitle()); - post.setUrlPath(postDto.getUrlPath()); - post.setTags(postDto.getTags()); - post.setCategoryId(postDto.getCategoryId()); // 추가됨 - Post updatedPost = postRepository.save(post); - return postMapper.toDto(updatedPost); + return postMapper.toPostDetailDto(updatedPost); } - public void deletePost(Long id) { - postRepository.deleteById(id); + // 이미지 변경사항 감지 메서드 (helper) + private boolean hasImageChanges(PostUpdateRequestDto updateDto) { + // 새로운 이미지가 있거나, 유지할 이미지 목록이 명시되어 있으면 변경으로 간주 + boolean hasNewImages = updateDto.getNewImages() != null && !updateDto.getNewImages().isEmpty(); + boolean hasKeepImageIds = updateDto.getKeepImageIds() != null && !updateDto.getKeepImageIds().isEmpty(); + + return hasNewImages || hasKeepImageIds; + } + + private void updatePostImages(Post post, List keepImageIds, List newImages) { + // 기존 이미지 중 유지하지 않는 것만 삭제 + List existingImages = postImageRepository.findByPostAndDeletedAtIsNull(post); + + if (keepImageIds == null) { + keepImageIds = new ArrayList<>(); + } + + // 유지하지 않는 이미지만 soft delete + for (PostImage existingImage : existingImages) { + if (!keepImageIds.contains(existingImage.getId())) { + existingImage.softDelete(); + } + } + postImageRepository.saveAll(existingImages); + + // 새 이미지 추가 + if (newImages != null && !newImages.isEmpty()) { + List newPostImages = processPostImages(newImages, post); + postImageRepository.saveAll(newPostImages); + } + } + + /** + * 게시글 삭제 (본인 또는 관리자만 가능) + */ + @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.delete(); + + // 모든 이미지 soft delete 처리 + List images = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : images) { + image.softDelete(); + } + + postRepository.save(post); + postImageRepository.saveAll(images); } + + /** + * 이미지 처리를 위한 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); + } + } + } + + /** + * 사용자가 작성한 게시글 목록 조회 + */ + public Page getPostsByUserId(Long userId, Pageable pageable) { + return postRepository.findMyPostsOptimized(userId, pageable) + .map(postMapper::toMyPostListDto); + } + + /** + * 사용자가 추천한 게시글 목록 조회 + */ + public Page getRecommendedPostsByUserId(Long userId, Pageable pageable) { + return postRepository.findRecommendedPostsOptimized(userId, pageable) + .map(post -> { + MyPostListResponseDto dto = postMapper.toMyPostListDto(post); + return dto; + }); + } + + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java index ec24017..7cfd76c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java @@ -1,8 +1,8 @@ package com.hyetaekon.hyetaekon.publicservice.controller; +import com.hyetaekon.hyetaekon.publicservice.dto.FilterOptionDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceDetailResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; -import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; import jakarta.validation.constraints.Max; @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @Validated @@ -34,7 +35,7 @@ public ResponseEntity> getAllServices( @RequestParam(required = false) List familyTypes, @RequestParam(required = false) List categories, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size) { + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(50) int size) { Long userId = authenticateUser.authenticateUserId(); @@ -42,8 +43,13 @@ public ResponseEntity> getAllServices( sort, specialGroups, familyTypes, categories, PageRequest.of(page, size), userId)); } + @GetMapping("/filters") + public ResponseEntity>> getFilterOptions() { + return ResponseEntity.ok(publicServiceHandler.getFilterOptions()); + } + // 서비스 분야별 공공서비스 목록 조회 - @GetMapping("/category/{category}") + /*@GetMapping("/category/{category}") public ResponseEntity> getServicesByCategory ( @PathVariable("category") String categoryName, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, @@ -52,11 +58,11 @@ public ResponseEntity> getServicesByCategory ServiceCategory category = publicServiceHandler.getServiceCategory(categoryName); return ResponseEntity.ok(publicServiceHandler.getServicesByCategory(category, PageRequest.of(page, size), userId)); - } + }*/ // 공공서비스 상세 조회 @GetMapping("/detail/{serviceId}") - public ResponseEntity getServiceDetail (@PathVariable("serviceId") Long serviceId) { + public ResponseEntity getServiceDetail (@PathVariable("serviceId") String serviceId) { Long userId = authenticateUser.authenticateUserId(); return ResponseEntity.ok(publicServiceHandler.getServiceDetail(serviceId, userId)); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java new file mode 100644 index 0000000..4c87d07 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/RecentVisitController.java @@ -0,0 +1,57 @@ +package com.hyetaekon.hyetaekon.publicservice.controller; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.RecentVisitService; +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.*; + +@Validated +@RestController +@RequestMapping("/api/services/recent") +@RequiredArgsConstructor +public class RecentVisitController { + + private final RecentVisitService recentVisitService; + + /** + * 최근 방문한 서비스 목록 조회 + */ + @GetMapping + public ResponseEntity> getRecentVisits( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "3") @Positive @Max(10) int size, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails != null ? userDetails.getId() : 0L; + + // 비로그인 사용자는 빈 목록 반환 + if (userId == 0L) { + return ResponseEntity.ok(Page.empty(PageRequest.of(page, size))); + } + + Page recentServices = recentVisitService.getRecentVisits(userId, page, size); + return ResponseEntity.ok(recentServices); + } + + /** + * 방문 기록 전체 삭제 + */ + @DeleteMapping + public ResponseEntity clearRecentVisits( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + if (userDetails != null) { + recentVisitService.clearUserVisits(userDetails.getId()); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java deleted file mode 100644 index 5d44241..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; - -import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/services/matched") -@RequiredArgsConstructor -public class PublicServiceMatchedController { - - -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java index 68e14f7..12e908a 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,72 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import com.hyetaekon.hyetaekon.post.dto.PostListResponseDto; +import com.hyetaekon.hyetaekon.post.service.PostService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchHandler; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; + +import java.util.List; @Validated @RestController -@RequestMapping("/api/services/search") +@RequestMapping("/api/mongo/search") @RequiredArgsConstructor public class SearchInfoController { + private final ServiceSearchHandler searchService; + private final PostService postService; + private final AuthenticateUser authenticateUser; - // 검색(키워드 + 서비스 조건, 분야 선택) - - - // 자동완성 - - - // 검색 기록 전체 조회 - + // 검색 API (로그인/비로그인 통합) + @GetMapping("/services") + 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)); + } + } - // 검색 기록 전체 삭제 + // 게시글 제목 검색(통합검색) + @GetMapping("/posts") + public ResponseEntity> searchPosts( + @RequestParam String searchTerm, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(50) int size) { + return ResponseEntity.ok(postService.getAllPosts( + searchTerm, "createdAt", "DESC", PageRequest.of(page, size))); + } -} + // 자동완성 API(서비스에 대해서만) + @GetMapping("/services/autocomplete") + public ResponseEntity> getAutocompleteResults( + @RequestParam(name = "word") String word + ) { + return ResponseEntity.ok(searchService.getAutocompleteResults(word)); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java new file mode 100644 index 0000000..fe59802 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -0,0 +1,43 @@ +package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceMatchedHandler; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Validated +@RestController +@RequestMapping("/api/mongo/services/matched") +@RequiredArgsConstructor +public class ServiceMatchedController { + private final ServiceMatchedHandler serviceMatchedHandler; + + /** + * 사용자 맞춤 공공서비스 추천 API + * 사용자 프로필 및 검색 기록 기반으로 개인화된 서비스 목록 추천 + */ + @GetMapping + public ResponseEntity> getMatchedServices( + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(20) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 사용자 맞춤 추천 서비스 조회 + List matchedServices = + serviceMatchedHandler.getPersonalizedServices(userDetails.getId(), size); + + return ResponseEntity.ok(matchedServices); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java new file mode 100644 index 0000000..594dbe5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/FilterOptionDto.java @@ -0,0 +1,11 @@ +package com.hyetaekon.hyetaekon.publicservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FilterOptionDto { + private String code; + private String label; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java index c48c6eb..09a67d3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java @@ -11,7 +11,7 @@ @Builder @ToString public class PublicServiceDetailResponseDto { - private Long publicServiceId; + private String publicServiceId; private String serviceName; private String servicePurpose; // 서비스 목적 diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java index 1a217de..fade66a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java @@ -11,7 +11,7 @@ @Builder @ToString public class PublicServiceListResponseDto { - private Long publicServiceId; + private String publicServiceId; private String serviceName; private String summaryPurpose; diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java new file mode 100644 index 0000000..4642d69 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java @@ -0,0 +1,39 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import lombok.Getter; +import lombok.Builder; +import org.springframework.data.domain.Pageable; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Getter +@Builder +public class ServiceSearchCriteriaDto { + private final String searchTerm; // 검색어 + private final List userInterests; // 사용자 관심사 + private final String userGender; // 사용자 성별 + private final Integer userAge; // 사용자 나이 + private final String userJob; // 사용자 직종 + private final String userIncomeLevel; // 사용자 소득수준 + private final Pageable pageable; + + // 사용자 정보 추가 메서드 + public ServiceSearchCriteriaDto withUserInfo( + List userInterests, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + return ServiceSearchCriteriaDto.builder() + .searchTerm(this.searchTerm) + .userInterests(userInterests) + .userGender(userGender) + .userAge(userAge) + .userIncomeLevel(userIncomeLevel) + .userJob(userJob) + .pageable(this.pageable) + .build(); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java new file mode 100644 index 0000000..74d6cc6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java @@ -0,0 +1,30 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; +import lombok.AllArgsConstructor; +import lombok.*; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ServiceSearchResultDto { + private final List results; + private final long total; + private final int currentPage; + private final int totalPages; + private final boolean hasNext; + + public static ServiceSearchResultDto of(List results, long total, Pageable pageable) { + int totalPages = (int) Math.ceil((double) total / pageable.getPageSize()); + return ServiceSearchResultDto.builder() + .results(results) + .total(total) + .currentPage(pageable.getPageNumber()) + .totalPages(totalPages) + .hasNext(pageable.getPageNumber() + 1 < totalPages) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java new file mode 100644 index 0000000..e09d57d --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/CacheType.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.publicservice.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 + FILTER_OPTIONS("filterOptions", 24, 50), // 필터 옵션 캐시 (하루 유지) + MATCHED_SERVICES("matchedServices", 2, 100), // 맞춤 서비스 캐시 + SERVICE_BASIC_INFO("serviceBasicInfo", 12, 1000); // 서비스 기본 정보 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java index 5e8057f..6bdbb99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -16,75 +16,75 @@ @AllArgsConstructor public class PublicService { @Id - private Long id; + private String id; @Column(name = "service_name", nullable = false, length = 255) private String serviceName; // 서비스명 // 서비스 분야 - 카테고리 + 해시태그 @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "public_category", nullable = false) private ServiceCategory serviceCategory; // 서비스 분야 @Column(name = "summary_purpose", columnDefinition = "TEXT") private String summaryPurpose; // 서비스 목적 요약 - @Column(name = "governing_agency", nullable = false, length = 100) + @Column(name = "governing_agency", length = 100) private String governingAgency; // 소관기관명 - @Column(name = "department", nullable = false, length = 100) + @Column(name = "department", length = 100) private String department; // 부서명 - @Column(name = "user_type", nullable = false, length = 50) + @Column(name = "user_type", length = 50) private String userType; // 사용자 구분 // 지원 대상 필드 - @Column(name = "support_target", nullable = false, columnDefinition = "TEXT") + @Column(name = "support_target", columnDefinition = "TEXT") private String supportTarget; // 지원 대상 - @Column(name = "selection_criteria", nullable = false, columnDefinition = "TEXT") + @Column(name = "selection_criteria", columnDefinition = "TEXT") private String selectionCriteria; // 선정 기준 // 지원 관련 필드 - @Column(name = "service_purpose", nullable = false, columnDefinition = "TEXT") + @Column(name = "service_purpose", columnDefinition = "TEXT") private String servicePurpose; // 서비스 목적 - @Column(name = "support_detail", nullable = false, columnDefinition = "TEXT") + @Column(name = "support_detail", columnDefinition = "TEXT") private String supportDetail; // 지원 내용 - @Column(name = "support_type", nullable = false, length = 100) + @Column(name = "support_type", length = 100) private String supportType; // 지원 유형 // 신청 내용 필드 - @Column(name = "application_method", nullable = false, columnDefinition = "TEXT") + @Column(name = "application_method", columnDefinition = "TEXT") private String applicationMethod; // 신청 방법(상세) - @Column(name = "application_deadline", nullable = false, columnDefinition = "TEXT") + @Column(name = "application_deadline", columnDefinition = "TEXT") private String applicationDeadline; // 신청 기한(상세) // 추가정보 필드 - @Column(name = "required_documents", columnDefinition = "TEXT") - private String requiredDocuments; // 구비 서류 +// @Column(name = "required_documents", columnDefinition = "TEXT") +// private String requiredDocuments; // 구비 서류 - @Column(name = "contact_info", length = 255) + @Column(name = "contact_info", columnDefinition = "TEXT") private String contactInfo; // 문의처 @Column(name = "online_application_url", columnDefinition = "TEXT") private String onlineApplicationUrl; // 온라인 경로 url - @Column(name = "related_laws", columnDefinition = "TEXT") - private String relatedLaws; // 관련 법률 +// @Column(name = "related_laws", columnDefinition = "TEXT") +// private String relatedLaws; // 관련 법률 // 지원조건 필드 - 유저 정보 비교용 - @Column(name = "target_gender_male", nullable = false) + @Column(name = "target_gender_male") private String targetGenderMale; - @Column(name = "target_gender_Female", nullable = false) + @Column(name = "target_gender_female") private String targetGenderFemale; @Column(name = "target_age_start") @@ -118,19 +118,23 @@ public class PublicService { private Integer bookmarkCnt = 0; - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List specialGroups = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List familyTypes = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List occupations = new ArrayList<>(); - @OneToMany(mappedBy = "publicService",cascade = CascadeType.ALL,fetch = FetchType.LAZY) + @OneToMany(mappedBy = "publicService", cascade = {CascadeType.ALL}, + orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List businessTypes = new ArrayList<>(); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java index 68e5dd3..e3896b6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/ServiceCategory.java @@ -15,7 +15,11 @@ public enum ServiceCategory { EMPLOYMENT_STARTUP("고용·창업"), HEALTH_MEDICAL("보건·의료"), CULTURE_ENVIRONMENT("문화·환경"), - LIFE_STABILITY("생활안정"); + LIFE_STABILITY("생활안정"), + PROTECTION_CARE("보호·돌봄"), + PREGNANCY_CHILDBIRTH("임신·출산"), + OTHER("기타"); + @JsonValue private final String type; diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java new file mode 100644 index 0000000..e67b89c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java @@ -0,0 +1,38 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +public enum IncomeLevel { + VERY_LOW("0-50%", "LOW"), + LOW("51-75%", "MIDDLE_LOW"), + MEDIUM("76-100%", "MIDDLE"), + HIGH("101-200%", "MIDDLE_HIGH"), + VERY_HIGH("200%+", "HIGH"), + ANY("ANY", "ANY"); // 모든 소득수준 허용 + + private final String percentageRange; + private final String code; + + IncomeLevel(String percentageRange, String code) { + this.percentageRange = percentageRange; + this.code = code; + } + + // 퍼센트 범위에서 IncomeLevel 찾기 + public static IncomeLevel findByPercentageRange(String percentageRange) { + for (IncomeLevel level : values()) { + if (level.percentageRange.equals(percentageRange)) { + return level; + } + } + return null; + } + + // 코드에서 IncomeLevel 찾기 + public static IncomeLevel findByCode(String code) { + for (IncomeLevel level : values()) { + if (level.code.equals(code)) { + return level; + } + } + return null; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java new file mode 100644 index 0000000..50b965f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java @@ -0,0 +1,34 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "service_info") // 실제 몽고 DB 컬렉션 이름 +public class ServiceInfo { + private String publicServiceId; + private String serviceName; + private String summaryPurpose; + + // 해시태그 + private String serviceCategory; // 서비스 분야 + private List specialGroup; // 특수 대상 그룹 + private List familyType; // 가구 형태 + + private List occupations; + private List businessTypes; + + // Support conditions fields + private String targetGenderMale; + private String targetGenderFemale; + private Integer targetAgeStart; + private Integer targetAgeEnd; + private String incomeLevel; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java index 5f910f0..cd6982c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/PublicServiceMapper.java @@ -12,6 +12,9 @@ @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PublicServiceMapper { @Mapping(source = "id", target = "publicServiceId") + @Mapping(target = "serviceCategory", expression = "java(publicService.getServiceCategory().getType())") + @Mapping(target = "specialGroup", expression = "java(publicService.getSpecialGroups().stream().map(sg -> sg.getSpecialGroupEnum().getType()).collect(java.util.stream.Collectors.toList()))") + @Mapping(target = "familyType", expression = "java(publicService.getFamilyTypes().stream().map(ft -> ft.getFamilyTypeEnum().getType()).collect(java.util.stream.Collectors.toList()))") PublicServiceListResponseDto toListDto(PublicService publicService); @Mapping(source = "id", target = "publicServiceId") diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java new file mode 100644 index 0000000..2a46e27 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.publicservice.mapper.mongodb; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ServiceInfoMapper { + + @Mapping(source = "specialGroup", target = "specialGroup") + @Mapping(source = "familyType", target = "familyType") + PublicServiceListResponseDto toDto(ServiceInfo serviceInfo); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java index 54c4701..c7e436e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -16,21 +16,20 @@ import java.util.Optional; @Repository -public interface PublicServiceRepository extends JpaRepository { - Page findByServiceCategory(ServiceCategory category, Pageable pageable); +public interface PublicServiceRepository extends JpaRepository { List findTop6ByOrderByBookmarkCntDesc(); - Optional findById(long serviceId); + Optional findById(String serviceId); - int deleteByIdNotIn(List Ids); + int deleteByIdNotIn(List Ids); @Query("SELECT DISTINCT ps FROM PublicService ps " + "LEFT JOIN ps.specialGroups sg " + "LEFT JOIN ps.familyTypes ft " + "WHERE (:#{#categories == null || #categories.isEmpty()} = true OR ps.serviceCategory IN :categories) " + - "AND (:#{#specialGroupEnums == null || #specialGroupEnums.isEmpty()} = true OR sg.specialGroupEnum IN :specialGroupEnums) " + - "AND (:#{#familyTypeEnums == null || #familyTypeEnums.isEmpty()} = true OR ft.familyTypeEnum IN :familyTypeEnums)") + "AND (:#{#specialGroupEnums == null || #specialGroupEnums.isEmpty()} = true OR (sg IS NOT NULL AND sg.specialGroupEnum IN :specialGroupEnums)) " + + "AND (:#{#familyTypeEnums == null || #familyTypeEnums.isEmpty()} = true OR (ft IS NOT NULL AND ft.familyTypeEnum IN :familyTypeEnums))") Page findWithFilters( @Param("categories") List categories, @Param("specialGroupEnums") List specialGroupEnums, @@ -41,5 +40,4 @@ Page findWithFilters( // 사용자의 북마크 공공서비스 목록 페이지 @Query("SELECT p FROM PublicService p JOIN p.bookmarks b WHERE b.user.id = :userId ORDER BY b.createdAt DESC") Page findByBookmarks_User_Id(@Param("userId") Long userId, Pageable pageable); - } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java new file mode 100644 index 0000000..346d84b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java @@ -0,0 +1,318 @@ +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); + + // 성별과 나이 조건도 should로 변경 + addUserMatchBoosts(shouldClauses, userGender, userAge, userIncomeLevel, userJob); + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + // compound 쿼리 구성 (should 조건만 사용) + String compoundQuery = """ + compound: { + should: %s, + minimumShouldMatch: 1 + } + """.formatted(shouldClausesStr); + + 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, 3.5f)); + // 요약 검색 + clauses.add(createSearchClause("summaryPurpose", keyword, 3.5f)); + // 서비스 분야 검색 + clauses.add(createSearchClause("serviceCategory", keyword, 5.0f)); + // 특수그룹 검색 + clauses.add(createSearchClause("specialGroup", keyword, 5.0f)); + // 가족유형 검색 + clauses.add(createSearchClause("familyType", keyword, 5.0f)); + } + } + + return clauses; + } + + /** + * 사용자 정보 기반 가산점 추가 + */ + private void addUserMatchBoosts( + List clauses, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + + // 성별 조건 (should로 변경) + if (StringUtils.hasText(userGender)) { + String genderField = "MALE".equalsIgnoreCase(userGender) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스에 가산점 + clauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } + ], + score: {boost: {value: 5.5}} + } + } + """.formatted(genderField, genderField)); + } + + // 나이 조건 (should로 변경) + if (userAge != null) { + clauses.add(""" + { + range: { + path: ["targetAgeStart", "targetAgeEnd"], + gte: 0, + lte: %d, + score: { boost: { value: 4.5 } } + } + } + """.formatted(userAge)); + } + + // 직업 관련 조건 추가 + if (StringUtils.hasText(userJob)) { + clauses.add(createSearchClause("occupations", userJob, 3.0f)); + clauses.add(createSearchClause("businessTypes", userJob, 3.0f)); + } + + // 소득 수준 관련 조건 추가 + if (StringUtils.hasText(userIncomeLevel)) { + clauses.add(createSearchClause("incomeLevel", userIncomeLevel, 2.8f)); + clauses.add(createSearchClause("incomeLevel", "ANY", 1.0f)); + // 소득 수준 범위에 따른 가중치 추가 + addIncomeLevelRangeBoosts(clauses, userIncomeLevel); + } + } + + /** + * 검색 조건 생성 헬퍼 메서드 + */ + 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 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) + ); + } +} \ No newline at end of file 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..e21f506 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -0,0 +1,370 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ServiceSearchClient { + private final MongoTemplate mongoTemplate; + private static final String SEARCH_INDEX = "searchIndex"; + private static final String AUTOCOMPLETE_INDEX = "serviceAutocompleteIndex"; + private static final String COLLECTION_NAME = "service_info"; + + private static final String PROJECT_STAGE = """ + { + $project: { + publicServiceId: 1, + serviceName: 1, + summaryPurpose: 1, + serviceCategory: 1, + specialGroup: 1, + familyType: 1, + occupations: 1, + businessTypes: 1, + targetGenderMale: 1, + targetGenderFemale: 1, + targetAgeStart: 1, + targetAgeEnd: 1, + incomeLevel: 1, + score: {$meta: 'searchScore'} + } + }"""; + + public ServiceSearchResultDto search(ServiceSearchCriteriaDto criteria) { + List operations = new ArrayList<>(); + + // 검색 쿼리 추가 + operations.add(context -> Document.parse(buildSearchQuery(criteria))); + + // 프로젝션 추가 + operations.add(context -> Document.parse(PROJECT_STAGE)); + + // 페이징 처리 + operations.add(context -> Document.parse(buildFacetStage(criteria.getPageable()))); + + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation(operations), + COLLECTION_NAME, + Document.class + ); + + return processResults(results, criteria.getPageable()); + } + + private String buildSearchQuery(ServiceSearchCriteriaDto criteria) { + List shouldClauses = new ArrayList<>(); + + // 검색어 관련 조건 추가 + 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, 3.5f, 0)); + shouldClauses.add(createSearchClause("specialGroup", interest, 3.5f, 0)); + shouldClauses.add(createSearchClause("familyType", interest, 3.5f, 0)); + } + } + + // 사용자 정보(성별, 나이, 직업, 소득) 기반 조건 추가 + addUserMatchBoosts(shouldClauses, criteria); + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + // compound 쿼리 구성 (should 조건만 사용) + String compoundQuery = """ + compound: { + should: %s, + minimumShouldMatch: 1 + } + """.formatted(shouldClausesStr); + + 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 void addUserMatchBoosts(List clauses, ServiceSearchCriteriaDto criteria) { + // 성별 조건 (should로 변경) + if (StringUtils.hasText(criteria.getUserGender())) { + String genderField = "MALE".equalsIgnoreCase(criteria.getUserGender()) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스에 가산점 + clauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "%s"}}] + } + }, + { + equals: {path: "%s", value: "Y"} + } + ], + score: {boost: {value: 5.0}} + } + } + """.formatted(genderField, genderField)); + } + + // 나이 조건 (should로 변경) + if (criteria.getUserAge() != null) { + int age = criteria.getUserAge(); + + // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스에 가산점 + clauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeStart"}}] + } + }, + { + range: {path: "targetAgeStart", lte: %d} + } + ], + score: {boost: {value: 4.5}} + } + } + """.formatted(age)); + + clauses.add(""" + { + compound: { + should: [ + { + compound: { + mustNot: [{exists: {path: "targetAgeEnd"}}] + } + }, + { + range: {path: "targetAgeEnd", gte: %d} + } + ], + score: {boost: {value: 4.5}} + } + } + """.formatted(age)); + } + + // 직업 일치 가산점 + if (StringUtils.hasText(criteria.getUserJob())) { + String userJob = criteria.getUserJob(); + + // Occupation 필드와 일치 시 가산점 + clauses.add(createSearchClause("occupations", userJob, 3.0f, 0)); + + // BusinessType 필드와 일치 시 가산점 + clauses.add(createSearchClause("businessTypes", userJob, 3.0f, 0)); + } + + // 소득 수준 일치 가산점 + if (StringUtils.hasText(criteria.getUserIncomeLevel())) { + String userIncomeLevel = criteria.getUserIncomeLevel(); + + // 1. 정확히 일치하는 소득수준에 높은 가산점 + clauses.add(""" + {text: { + query: '%s', + path: 'incomeLevel', + score: {boost: {value: 2.8}} + }}""".formatted(userIncomeLevel)); + + // 2. ANY 값은 모든 소득수준에 매칭 가능 + clauses.add(""" + {text: { + query: 'ANY', + path: 'incomeLevel', + score: {boost: {value: 1.0}} + }}"""); + + // 3. 사용자 소득수준보다 낮은 범위도 포함 (범위별 가산점) + addIncomeLevelRangeBoosts(clauses, userIncomeLevel); + } + } + + // 소득수준 범위에 따른 가산점 추가 + private void addIncomeLevelRangeBoosts(List clauses, String userIncomeLevel) { + switch (userIncomeLevel) { + case "HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_HIGH", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 1.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.0f, 0)); + break; + case "MIDDLE_HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE_LOW": + clauses.add(createSearchClause("incomeLevel", "LOW", 2.0f, 0)); + break; + default: + break; + } + } + + private String createSearchClause(String path, String query, float boost, int maxEdits) { + return """ + {text: { + query: '%s', + path: '%s', + score: {boost: {value: %.1f}}%s + }}""".formatted( + query, + path, + boost, + maxEdits > 0 ? ", fuzzy: {maxEdits: " + maxEdits + "}" : "" + ); + } + + private String buildFacetStage(Pageable pageable) { + return """ + { + $facet: { + results: [{$skip: %d}, {$limit: %d}], + total: [{$count: 'count'}] + } + }""".formatted(pageable.getOffset(), pageable.getPageSize()); + } + + private ServiceSearchResultDto processResults(AggregationResults results, Pageable pageable) { + Document result = results.getUniqueMappedResult(); + if (result == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List resultDocs = result.get("results", List.class); + List totalDocs = result.get("total", List.class); + + if (resultDocs == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List searchResults = resultDocs.stream() + .map(doc -> mongoTemplate.getConverter().read(ServiceInfo.class, doc)) + .toList(); + + long total = 0L; + if (totalDocs != null && !totalDocs.isEmpty()) { + Number count = totalDocs.getFirst().get("count", Number.class); + total = count != null ? count.longValue() : 0L; + } + + // 중복 제거: publicServiceId가 같은 경우 하나만 유지 + Map uniqueResults = new LinkedHashMap<>(); + for (ServiceInfo info : searchResults) { + uniqueResults.putIfAbsent(info.getPublicServiceId(), info); + } + + List dedupedResults = new ArrayList<>(uniqueResults.values()); + + // 중복 제거 로깅 + int removedDuplicates = searchResults.size() - dedupedResults.size(); + if (removedDuplicates > 0) { + log.warn("검색 결과에서 중복된 항목 {}개가 제거되었습니다.", removedDuplicates); + } + + return ServiceSearchResultDto.of(dedupedResults, total, pageable); + } + + // 검색어 자동완성 + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + + return mongoTemplate.aggregate( + Aggregation.newAggregation( + context -> Document.parse(""" + { + $search: { + index: '%s', + autocomplete: { + query: '%s', + path: 'serviceName', + fuzzy: {maxEdits: 1} + } + } + }""".formatted(AUTOCOMPLETE_INDEX, word)), + Aggregation.project("serviceName"), + Aggregation.limit(8) + ), + COLLECTION_NAME, + Document.class + ) + .getMappedResults() + .stream() + .map(doc -> doc.getString("serviceName")) + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 0a59a48..f306a02 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -3,6 +3,7 @@ import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; import com.hyetaekon.hyetaekon.common.exception.ErrorCode; import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.publicservice.dto.FilterOptionDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceDetailResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.entity.FamilyTypeEnum; @@ -13,6 +14,8 @@ import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; import com.hyetaekon.hyetaekon.publicservice.util.PublicServiceValidate; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -21,21 +24,20 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class PublicServiceHandler { private final PublicServiceRepository publicServiceRepository; private final PublicServiceMapper publicServiceMapper; private final PublicServiceValidate publicServiceValidate; private final BookmarkRepository bookmarkRepository; + private final RecentVisitService recentVisitService; // 서비스분야별 서비스목록 조회(페이지) - 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,17 +48,28 @@ public Page getServicesByCategory(ServiceCategory } return dto; }); - } + }*/ + + /* public ServiceCategory getServiceCategory(String categoryName) { + return publicServiceValidate.validateServiceCategory(categoryName); + }*/ // 서비스 상세 조회 @Transactional - public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long userId) { + public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long userId) { PublicService service = publicServiceValidate.validateServiceById(serviceId); + // 필수 필드가 null인지 검증하는 로직 추가 + if (publicServiceValidate.isDetailInformationIncomplete(service)) { + throw new GlobalException(ErrorCode.INCOMPLETE_SERVICE_DETAIL); + } + // 조회수 증가 service.updateViewsUp(); publicServiceRepository.save(service); + recentVisitService.addVisit(userId, serviceId); + PublicServiceDetailResponseDto dto = publicServiceMapper.toDetailDto(service); if (userId != 0L) { @@ -67,17 +80,16 @@ public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long user return dto; } - // 인기 서비스 목록 조회(6개 고정) + // 인기 서비스 목록 조회(6개 고정) - 캐싱적용 + @Transactional(readOnly = true) public List getPopularServices(Long userId) { // 북마크 수 기준으로 상위 6개 서비스 조회 - List services = publicServiceRepository.findTop6ByOrderByBookmarkCntDesc(); - - return services.stream() + return publicServiceRepository.findTop6ByOrderByBookmarkCntDesc().stream() .map(service -> { PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); // 로그인한 사용자는 북마크 여부 확인 - if (userId == 0L) { + if (userId != 0L) { dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); } return dto; @@ -85,11 +97,8 @@ public List getPopularServices(Long userId) { .collect(Collectors.toList()); } - public ServiceCategory getServiceCategory(String categoryName) { - return publicServiceValidate.validateServiceCategory(categoryName); - } - // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) + @Transactional(readOnly = true) public Page getAllServices( String sort, List specialGroups, @@ -99,18 +108,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 +130,7 @@ public Page getAllServices( PageRequest pageRequest = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), - Sort.by(direction, sortField) + sorts ); // 필터링 조건에 따른 서비스 조회 @@ -142,10 +150,16 @@ public Page getAllServices( List specialGroupEnums = new ArrayList<>(); if (specialGroups != null) { for (String group : specialGroups) { - try { - SpecialGroupEnum enumValue = SpecialGroupEnum.valueOf(group); - specialGroupEnums.add(enumValue); - } catch (IllegalArgumentException e) { + // type 값으로 Enum 찾기 + boolean found = false; + for (SpecialGroupEnum enumValue : SpecialGroupEnum.values()) { + if (enumValue.getType().equals(group)) { + specialGroupEnums.add(enumValue); + found = true; + break; + } + } + if (!found) { throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); } } @@ -154,10 +168,16 @@ public Page getAllServices( List familyTypeEnums = new ArrayList<>(); if (familyTypes != null) { for (String type : familyTypes) { - try { - FamilyTypeEnum enumValue = FamilyTypeEnum.valueOf(type); - familyTypeEnums.add(enumValue); - } catch (IllegalArgumentException e) { + // type 값으로 Enum 찾기 + boolean found = false; + for (FamilyTypeEnum enumValue : FamilyTypeEnum.values()) { + if (enumValue.getType().equals(type)) { + familyTypeEnums.add(enumValue); + found = true; + break; + } + } + if (!found) { throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); } } @@ -184,7 +204,42 @@ public Page getAllServices( }); } - public Page getBookmarkedServices(Long userId, Pageable pageable) { + // 필터 옵션 조회 (캐싱 적용) + @Transactional(readOnly = true) + @Cacheable(value = "filterOptions") + public Map> getFilterOptions() { + Map> filterOptions = new HashMap<>(); + + // 서비스 분야 (카테고리) 옵션 + List categoryOptions = Arrays.stream(ServiceCategory.values()) + .map(category -> new FilterOptionDto(category.name(), category.getType())) + .collect(Collectors.toList()); + filterOptions.put("categories", categoryOptions); + + // 특수 그룹 (가구형태) 옵션 + List specialGroupOptions = Arrays.stream(SpecialGroupEnum.values()) + .map(group -> new FilterOptionDto(group.name(), group.getType())) + .collect(Collectors.toList()); + filterOptions.put("specialGroups", specialGroupOptions); + + // 가족 유형 (가구상황) 옵션 + List familyTypeOptions = Arrays.stream(FamilyTypeEnum.values()) + .map(type -> new FilterOptionDto(type.name(), type.getType())) + .collect(Collectors.toList()); + filterOptions.put("familyTypes", familyTypeOptions); + + return filterOptions; + } + + // 필터 옵션 캐시 무효화 - Enum이 변경될 때 + @Transactional + @CacheEvict(value = "filterOptions", allEntries = true) + public void refreshFilterOptions() { + } + + // 내가 북마크한 서비스 목록 조회 + @Transactional(readOnly = true) + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); List serviceDtos = bookmarkedServices.getContent().stream() @@ -197,5 +252,6 @@ public Page getBookmarkedServices(Long userId, Pag return new PageImpl<>(serviceDtos, pageable, bookmarkedServices.getTotalElements()); - } + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java new file mode 100644 index 0000000..fe8d47b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/RecentVisitService.java @@ -0,0 +1,152 @@ +package com.hyetaekon.hyetaekon.publicservice.service; + +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.publicservice.mapper.PublicServiceMapper; +import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecentVisitService { + + private final RedisTemplate redisTemplate; + private final PublicServiceRepository publicServiceRepository; + private final PublicServiceMapper publicServiceMapper; + private final BookmarkRepository bookmarkRepository; + + // Redis 키 형식: "user:{userId}:recentServices" + private static final String KEY_PREFIX = "user:"; + private static final String KEY_SUFFIX = ":recentServices"; + + // 설정값 + private static final int MAX_ITEMS = 10; // 최대 저장 개수 + private static final int TTL_DAYS = 30; // 데이터 유지 기간(일) + + /** + * 사용자의 서비스 방문 기록 추가 + */ + public void addVisit(Long userId, String serviceId) { + if (userId == null || userId == 0 || serviceId == null) { + return; // 비로그인 사용자나 유효하지 않은 ID는 처리하지 않음 + } + + String key = generateKey(userId); + + // 이미 존재하는 경우 삭제 (중복 제거) + redisTemplate.opsForList().remove(key, 0, serviceId); + + // 맨 앞에 새로 추가 (최신 방문) + redisTemplate.opsForList().leftPush(key, serviceId); + + // 최대 10개로 제한 + redisTemplate.opsForList().trim(key, 0, MAX_ITEMS - 1); + + // TTL 설정 (30일) + redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); + + log.debug("사용자 {} - 최근 방문 서비스 추가: {}", userId, serviceId); + } + + /** + * 최근 방문 서비스 목록 조회 (페이징) + * - 서비스 ID 목록: Redis에서 조회 + * - 서비스 기본 정보: Caffeine 캐시 활용 (변경이 적은 정보) + * - 북마크 상태: 직접 DB 조회 (자주 변경되는 정보) + */ + public Page getRecentVisits(Long userId, int page, int size) { + String key = generateKey(userId); + + // 전체 개수 확인 + Long total = redisTemplate.opsForList().size(key); + if (total == null || total == 0) { + return Page.empty(PageRequest.of(page, size)); + } + + // 현재 페이지에 해당하는 범위 계산 + int start = page * size; + int end = start + size - 1; + + // Redis에서 ID 목록 조회 + List serviceIds = redisTemplate.opsForList().range(key, start, end); + if (serviceIds == null || serviceIds.isEmpty()) { + return Page.empty(PageRequest.of(page, size)); + } + + // 서비스 정보 조회 (ID별로 분리하여 처리) + List services = new ArrayList<>(); + for (String id : serviceIds) { + // 기본 정보는 캐시 활용 (캐시 실패 시 직접 조회) + PublicService service = getServiceBasicInfo(id); + if (service != null) { + services.add(service); + } + } + + // DTO 변환 및 북마크 정보 설정 + List dtoList = services.stream() + .map(service -> { + // 기본 정보 변환 + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + + // 북마크 상태는 항상 직접 조회 (자주 변경되는 정보) + if (userId != 0L) { + dto.setBookmarked(isBookmarked(userId, service.getId())); + } + + return dto; + }) + .collect(Collectors.toList()); + + // 페이지 객체 생성 및 반환 + return new PageImpl<>(dtoList, PageRequest.of(page, size), total); + } + + /** + * 서비스 기본 정보 조회 (캐시 적용) + * - 서비스 이름, 설명 등 자주 변경되지 않는 정보에 캐시 적용 + */ + @Cacheable(value = "serviceBasicInfo", key = "#serviceId", unless = "#result == null") + public PublicService getServiceBasicInfo(String serviceId) { + log.debug("Cache miss: 서비스 기본 정보 DB 조회 - ID: {}", serviceId); + return publicServiceRepository.findById(serviceId).orElse(null); + } + + /** + * 북마크 상태 조회 (항상 직접 DB 조회) + * - 자주 변경될 수 있어 캐시 미적용 + */ + public boolean isBookmarked(Long userId, String serviceId) { + return bookmarkRepository.existsByUserIdAndPublicServiceId(userId, serviceId); + } + + /** + * 특정 사용자의 전체 방문 기록 삭제 + */ + public void clearUserVisits(Long userId) { + String key = generateKey(userId); + redisTemplate.delete(key); + log.debug("사용자 {} - 방문 기록 전체 삭제", userId); + } + + /** + * Redis 키 생성 + */ + private String generateKey(Long userId) { + return KEY_PREFIX + userId + KEY_SUFFIX; + } +} \ No newline at end of file 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..d27b8c5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java @@ -0,0 +1,118 @@ +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("실업자")) { + return "LOW"; + } + + if (lowercaseJob.contains("공무원") || lowercaseJob.contains("인턴")) { + return "MIDDLE_LOW"; + } + + if (lowercaseJob.contains("직장인") || lowercaseJob.contains("회사원") + || lowercaseJob.contains("프리랜서")) { + return "MIDDLE"; + } + + if (lowercaseJob.contains("전문직") || lowercaseJob.contains("관리직")) { + return "MIDDLE_HIGH"; + } + + if (lowercaseJob.contains("임원") || lowercaseJob.contains("의사") + || lowercaseJob.contains("변호사")) { + return "HIGH"; + } + + return "MIDDLE_LOW"; // 기본값 + } +} 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..499d2b8 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceMatchedHandler.java @@ -0,0 +1,96 @@ +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.CacheEvict; +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()); + } + + @CacheEvict(value = "matchedServices", key = "#userId") + public void refreshMatchedServicesCache(Long userId) { + // 캐시 갱신 메서드 + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java new file mode 100644 index 0000000..dbaea0f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchHandler.java @@ -0,0 +1,138 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.searchHistory.Service.SearchHistoryService; +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.userInterest.repository.UserInterestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.repository.mongodb.ServiceSearchClient; +import com.hyetaekon.hyetaekon.publicservice.mapper.mongodb.ServiceInfoMapper; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; + +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ServiceSearchHandler { + private final ServiceSearchClient serviceSearchClient; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + private final SearchHistoryService searchHistoryService; + + // 기본 검색 (비로그인) + public Page searchServices(ServiceSearchCriteriaDto criteria) { + // 검색 조건이 없는 경우 빈 결과 반환 + if (!StringUtils.hasText(criteria.getSearchTerm())) { + return Page.empty(criteria.getPageable()); + } + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(criteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 없이) + return convertToPageResponse(searchResult, null); + } + + // 맞춤 검색 (로그인) + public Page searchPersonalizedServices( + ServiceSearchCriteriaDto criteria, Long userId) { + + // 검색 조건이 없는 경우 빈 결과 반환 + if (!StringUtils.hasText(criteria.getSearchTerm())) { + return Page.empty(criteria.getPageable()); + } else if(StringUtils.hasText(criteria.getSearchTerm())) { // 검색어가 유효하면 검색 기록 저장 + searchHistoryService.saveSearchHistory(userId, criteria.getSearchTerm()); + } + + // 사용자 정보 가져오기 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID입니다.")); + + String userIncomeLevel = incomeEstimationHandler.determineIncomeLevelFromJob(user.getJob()); + + // 사용자 관심사 목록 추출 + List userInterests = userInterestRepository.findByUserId(userId).stream() + .map(UserInterest::getInterest) + .collect(Collectors.toList()); + + // 사용자 정보로 검색 조건 보강 + ServiceSearchCriteriaDto enrichedCriteria = criteria.withUserInfo( + userInterests, + user.getGender(), + calculateAge(user.getBirthAt()), + userIncomeLevel, + user.getJob() + ); + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(enrichedCriteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 포함) + return convertToPageResponse(searchResult, userId); + } + + private Page convertToPageResponse( + ServiceSearchResultDto searchResult, Long userId) { + + List dtoList = searchResult.getResults().stream() + .map(serviceInfo -> { + // Entity를 DTO로 변환 + PublicServiceListResponseDto dto = serviceInfoMapper.toDto(serviceInfo); + + // 사용자 ID가 제공된 경우 북마크 정보 설정 + if (userId != null) { + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId( + userId, serviceInfo.getPublicServiceId())); + } + + return dto; + }) + .collect(Collectors.toList()); + + return new PageImpl<>( + dtoList, + PageRequest.of(searchResult.getCurrentPage(), + searchResult.getResults().isEmpty() ? 10 : searchResult.getResults().size()), + searchResult.getTotal() + ); + } + + // 나이 계산 헬퍼 메서드 + public Integer calculateAge(LocalDate birthDate) { + if (birthDate == null) return null; + return Period.between(birthDate, LocalDate.now()).getYears(); + } + + // 자동완성 기능 - 캐싱 적용 + @Cacheable(value = "serviceAutocomplete", key = "#word", unless = "#result.isEmpty()") + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + return serviceSearchClient.getAutocompleteResults(word); + } + + // 자동완성 캐시 무효화 + @CacheEvict(value = "serviceAutocomplete", allEntries = true) + public void refreshAutocompleteCache() { + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java index c7bb87f..daae86f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java @@ -17,7 +17,7 @@ public class PublicServiceValidate { public final PublicServiceRepository publicServiceRepository; public final UserRepository userRepository; - public PublicService validateServiceById(Long serviceId) { + public PublicService validateServiceById(String serviceId) { return publicServiceRepository.findById(serviceId) .orElseThrow(() -> new GlobalException(ErrorCode.SERVICE_NOT_FOUND_BY_ID)); } @@ -28,10 +28,38 @@ public User validateUserById(Long userId) { } public ServiceCategory validateServiceCategory(String categoryName) { - try { + /*try { return ServiceCategory.valueOf(categoryName); } catch (IllegalArgumentException e) { throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + }*/ + for (ServiceCategory category : ServiceCategory.values()) { + if (category.getType().equals(categoryName)) { + return category; + } } + throw new GlobalException(ErrorCode.SERVICE_CATEGORY_NOT_FOUND); + } + + /** + * 서비스 상세 정보의 완전성 검증 + * @param service 검증할 공공서비스 객체 + * @return 상세 정보가 불완전하면 true, 충분히 완전하면 false 반환 + */ + public boolean isDetailInformationIncomplete(PublicService service) { + // 필수 필드 중 일정 개수 이상 누락된 경우 true 반환 + int nullCount = 0; + + if (service.getServicePurpose() == null || service.getServicePurpose().isEmpty()) nullCount++; + if (service.getSupportTarget() == null || service.getSupportTarget().isEmpty()) nullCount++; + if (service.getSupportDetail() == null || service.getSupportDetail().isEmpty()) nullCount++; + if (service.getSupportType() == null || service.getSupportType().isEmpty()) nullCount++; + if (service.getApplicationMethod() == null || service.getApplicationMethod().isEmpty()) nullCount++; + if (service.getApplicationDeadline() == null || service.getApplicationDeadline().isEmpty()) nullCount++; + if (service.getGoverningAgency() == null || service.getGoverningAgency().isEmpty()) nullCount++; + if (service.getContactInfo() == null || service.getContactInfo().isEmpty()) nullCount++; + + // 필수 필드 중 3개 이상 누락되면 불완전한 데이터로 판단 + return nullCount >= 3; } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java new file mode 100644 index 0000000..4e00b88 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/ServiceCacheManager.java @@ -0,0 +1,31 @@ +package com.hyetaekon.hyetaekon.publicservice.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ServiceCacheManager { + + private final CacheManager cacheManager; + + /** + * 특정 서비스의 기본 정보 캐시 삭제 + */ + @CacheEvict(value = "serviceBasicInfo", key = "#serviceId") + public void evictServiceBasicInfoCache(String serviceId) { + log.debug("서비스 기본 정보 캐시 삭제: {}", serviceId); + } + + /** + * 서비스 데이터 동기화 시 캐시 일괄 삭제 + */ + public void clearAllServiceCaches() { + cacheManager.getCache("serviceBasicInfo").clear(); + log.info("모든 서비스 기본 정보 캐시 삭제 완료"); + } +} 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/entity/Recommend.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java index 6ec90f0..8fc9ef8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; @@ -15,17 +16,17 @@ @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor -public class Recommend { +public class Recommend extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) private Post post; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java index fe5dd34..ca5baa8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java @@ -12,5 +12,4 @@ public interface RecommendRepository extends JpaRepository { Optional findByUserIdAndPostId(Long userId, Long postId); - int countByPostId(Long postId); } 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..ac5af1e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java @@ -1,12 +1,18 @@ package com.hyetaekon.hyetaekon.recommend.service; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; +import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; + @Service @Transactional @RequiredArgsConstructor @@ -15,4 +21,40 @@ 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(); + postRepository.save(post); + } + + @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(); + postRepository.save(post); + } } 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..b96c4b5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/Service/SearchHistoryService.java @@ -0,0 +1,174 @@ +package com.hyetaekon.hyetaekon.searchHistory.Service; + +import com.hyetaekon.hyetaekon.searchHistory.Dto.SearchHistoryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchHistoryService { + + private final RedisTemplate redisTemplate; + + // Redis 키 관련 상수 + private static final String KEY_PREFIX = "searchHistory:"; + private static final int MAX_HISTORY_COUNT = 6; + private static final int TTL_DAYS = 60; + + /** + * 검색 기록 저장 + */ + public void saveSearchHistory(Long userId, String searchTerm) { + if (userId == null || userId == 0L || searchTerm == null || searchTerm.trim().isEmpty()) { + log.debug("검색 기록 저장 건너뜀: 유효하지 않은 매개변수"); + return; + } + + try { + String key = generateKey(userId); + ListOperations listOps = redisTemplate.opsForList(); + + // 중복 검색어 제거 + listOps.remove(key, 0, searchTerm); + + // 새 검색어 추가 (왼쪽에서부터 = 최신 순) + listOps.leftPush(key, searchTerm); + + // 최대 6개로 제한 + listOps.trim(key, 0, MAX_HISTORY_COUNT - 1); + + // 만료 시간 설정 + redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); + + log.debug("사용자 {} 검색 기록 저장 완료: {}", userId, searchTerm); + } catch (Exception e) { + log.error("사용자 {} 검색 기록 저장 중 오류 발생: {}", userId, e.getMessage(), e); + } + } + + /** + * 사용자 검색 기록 조회 + */ + public List getUserSearchHistories(Long userId) { + if (userId == null || userId == 0L) { + return new ArrayList<>(); + } + + try { + String key = generateKey(userId); + ListOperations listOps = redisTemplate.opsForList(); + + // 전체 검색 기록 조회 (최신순) + List searchTerms = listOps.range(key, 0, -1); + if (searchTerms == null || searchTerms.isEmpty()) { + return new ArrayList<>(); + } + + // DTO로 변환 (ID는 검색어의 해시값 사용) + List result = new ArrayList<>(); + for (int i = 0; i < searchTerms.size(); i++) { + String searchTerm = searchTerms.get(i); + // 고유 ID 생성: 검색어 자체의 해시코드 사용 (인덱스 추가로 동일 검색어 구분) + String id = String.valueOf(userId + "_" + i + "_" + searchTerm.hashCode()); + + result.add(SearchHistoryDto.builder() + .id(id) + .searchTerm(searchTerm) + .createdAt(LocalDateTime.now().minusMinutes(i).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build()); + } + + return result; + } catch (Exception e) { + log.error("사용자 {} 검색 기록 조회 중 오류 발생: {}", userId, e.getMessage(), e); + return new ArrayList<>(); + } + } + + /** + * 특정 검색 기록 삭제 (인덱스 기반) + */ + public void deleteSearchHistory(Long userId, String historyId) { + if (userId == null || userId == 0L || historyId == null) { + return; + } + + try { + String key = generateKey(userId); + + // 현재 검색어 목록 가져오기 + List searchTerms = redisTemplate.opsForList().range(key, 0, -1); + if (searchTerms == null || searchTerms.isEmpty()) { + return; + } + + // ID 파싱하여 인덱스와 해시코드 추출 + String[] parts = historyId.split("_"); + if (parts.length < 3) { + log.warn("잘못된 히스토리 ID 형식: {}", historyId); + return; + } + + int index; + try { + index = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + log.warn("히스토리 ID에서 인덱스 파싱 실패: {}", historyId); + return; + } + + // 인덱스 유효성 검사 + if (index < 0 || index >= searchTerms.size()) { + log.warn("유효하지 않은 인덱스: {}", index); + return; + } + + // 해당 인덱스의 검색어 삭제 + String searchTerm = searchTerms.get(index); + redisTemplate.opsForList().remove(key, 0, searchTerm); + + log.debug("사용자 {} 검색 기록 삭제 완료, 검색어: {}", userId, searchTerm); + } catch (Exception e) { + log.error("사용자 {} 검색 기록 삭제 중 오류 발생: {}", userId, e.getMessage(), e); + } + } + + /** + * 사용자 검색 기록 전체 삭제 + */ + public void deleteAllSearchHistories(Long userId) { + if (userId == null || userId == 0L) { + return; + } + + try { + String key = generateKey(userId); + Boolean deleted = redisTemplate.delete(key); + + if (Boolean.TRUE.equals(deleted)) { + log.debug("사용자 {} 검색 기록 전체 삭제 완료", userId); + } else { + log.warn("사용자 {} 검색 기록 전체 삭제 실패: 키가 존재하지 않음", userId); + } + } catch (Exception e) { + log.error("사용자 {} 검색 기록 전체 삭제 중 오류 발생: {}", userId, e.getMessage(), e); + } + } + + /** + * 사용자별 Redis 키 생성 + */ + private String generateKey(Long userId) { + return KEY_PREFIX + userId; + } +} \ No newline at end of file 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..4c0be68 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/controller/SearchHistoryController.java @@ -0,0 +1,50 @@ +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("historyId") String historyId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + searchHistoryService.deleteSearchHistory(userDetails.getId(), historyId); + return ResponseEntity.ok().build(); + } + + /** + * 모든 검색 기록 삭제 + */ + @DeleteMapping + public ResponseEntity deleteAllSearchHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + searchHistoryService.deleteAllSearchHistories(userDetails.getId()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java new file mode 100644 index 0000000..01de45d --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/searchHistory/entity/SearchHistory.java @@ -0,0 +1,36 @@ +package com.hyetaekon.hyetaekon.searchHistory.entity; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@RedisHash(value = "searchHistory", timeToLive = 2592000) // 30일 유지 +public class SearchHistory implements Serializable { + @Id + private String id; // userId:timestamp 형태로 구성 + + @Indexed // 인덱싱으로 특정 사용자의 검색 기록 조회 가능 + private Long userId; + + private String searchTerm; + private LocalDateTime createdAt; + + // 팩토리 메서드 + public static SearchHistory of(Long userId, String searchTerm) { + String id = userId + ":" + System.currentTimeMillis(); + return SearchHistory.builder() + .id(id) + .userId(userId) + .searchTerm(searchTerm) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java index fc3d080..3a89bca 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java @@ -24,6 +24,7 @@ public class AuthController { private Long refreshTokenExpired; private final AuthService authService; + private final CookieUtil cookieUtil; // 로그인 처리 @PostMapping("/login") @@ -31,7 +32,7 @@ public ResponseEntity login(@RequestBody UserSignInRequest HttpServletResponse response) { JwtToken jwtToken = authService.login(userSignInRequestDto); - CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(),refreshTokenExpired); + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(),refreshTokenExpired); return ResponseEntity.ok(new UserTokenResponseDto(jwtToken.getAccessToken())); } @@ -63,7 +64,7 @@ public ResponseEntity refreshAccessToken( } JwtToken jwtToken = authService.refresh(refreshToken); - CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), refreshTokenExpired); + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), refreshTokenExpired); // JWT 토큰 정보 반환 return ResponseEntity.ok(new UserTokenResponseDto(jwtToken.getAccessToken())); diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java index 6af4604..a5ec4ec 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java @@ -1,8 +1,11 @@ package com.hyetaekon.hyetaekon.user.controller; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; import com.hyetaekon.hyetaekon.user.dto.admin.UserSuspendRequestDto; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; import com.hyetaekon.hyetaekon.user.service.UserAdminService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,7 +35,7 @@ public ResponseEntity> getAllUsers( */ @PostMapping("/users/{userId}/suspend") public ResponseEntity suspendUser( - @PathVariable Long userId, + @PathVariable("userId") Long userId, @RequestBody UserSuspendRequestDto requestDto) { userAdminService.suspendUser(userId, requestDto); return ResponseEntity.ok().build(); @@ -42,7 +45,7 @@ public ResponseEntity suspendUser( * 정지 해제 */ @PutMapping("/users/{userId}/unsuspend") - public ResponseEntity unsuspendUser(@PathVariable Long userId) { + public ResponseEntity unsuspendUser(@PathVariable("userId") Long userId) { userAdminService.unsuspendUser(userId); return ResponseEntity.ok().build(); } @@ -77,4 +80,42 @@ public ResponseEntity> getUserReports( return ResponseEntity.ok(userAdminService.getUserReports(page, size)); } + /** + * 상태별 신고 내역 조회 + */ + @GetMapping("/users/reports/status/{status}") + public ResponseEntity> getReportsByStatus( + @PathVariable("status") ReportStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getReportsByStatus(status, page, size)); + } + + /** + * 신고 승인 처리 + */ + @PostMapping("/users/reports/{reportId}/resolve") + public ResponseEntity resolveReport( + @PathVariable("reportId") Long reportId, + @RequestParam(defaultValue = "false") boolean suspendUser, + @RequestBody(required = false) UserSuspendRequestDto suspendRequestDto) { + + // 사용자 정지 요청이 있지만 정지 정보가 없는 경우 + if (suspendUser && suspendRequestDto == null) { + throw new GlobalException(ErrorCode.INVALID_REPORT_REQUEST); + } + + userAdminService.resolveReport(reportId, suspendUser, suspendRequestDto); + return ResponseEntity.ok().build(); + } + + /** + * 신고 거부 처리 + */ + @PostMapping("/users/reports/{reportId}/reject") + public ResponseEntity rejectReport(@PathVariable("reportId") Long reportId) { + userAdminService.rejectReport(reportId); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java index 6546a5d..40af042 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -1,10 +1,20 @@ package com.hyetaekon.hyetaekon.user.controller; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserPrincipal; +import com.hyetaekon.hyetaekon.common.jwt.JwtTokenParser; +import com.hyetaekon.hyetaekon.common.jwt.JwtTokenProvider; +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.*; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import com.hyetaekon.hyetaekon.user.service.UserService; +import io.jsonwebtoken.Claims; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -27,6 +37,9 @@ public class UserController { private final UserService userService; private final PublicServiceHandler publicServiceHandler; + private final PostService postService; + private final JwtTokenParser jwtTokenParser; + private final UserRepository userRepository; // 회원 가입 api @PostMapping("/signup") @@ -44,6 +57,13 @@ public ResponseEntity getMyInfo(@AuthenticationPrincipal Custom return ResponseEntity.ok(userInfo); } + @GetMapping("/users/{userId}") + public ResponseEntity getUserById(@PathVariable Long userId) { + UserResponseDto user = userService.getUserById(userId); + return ResponseEntity.ok(user); + } + + // 회원 정보 수정 api @PutMapping("/users/me/profile") public ResponseEntity updateMyProfile( @@ -66,16 +86,48 @@ public ResponseEntity updateMyPassword( } // 회원 탈퇴 - @DeleteMapping("/users/me") + /*@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(); + }*/ + @DeleteMapping("/users/me") + public ResponseEntity deleteUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody UserDeleteRequestDto deleteRequestDto, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @RequestHeader("Authorization") String authHeader + ) { + log.debug("회원 탈퇴 요청 - 인증 객체: {}", userDetails); + + if (userDetails == null) { + // 인증 객체가 null일 경우 토큰에서 직접 정보 추출 + try { + String token = authHeader.replace("Bearer ", ""); + Claims claims = jwtTokenParser.parseClaims(token); + String realId = claims.getSubject(); + + User user = userRepository.findByRealIdAndDeletedAtIsNull(realId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_REAL_ID)); + + userService.deleteUser(user.getId(), deleteRequestDto.getDeleteReason(), token, refreshToken); + return ResponseEntity.noContent().build(); + } catch (Exception e) { + log.error("회원 탈퇴 처리 실패: {}", e.getMessage()); + throw new GlobalException(ErrorCode.DELETE_USER_DENIED); + } + } + + // 기존 로직 + String accessToken = authHeader.replace("Bearer ", ""); + userService.deleteUser(userDetails.getId(), deleteRequestDto.getDeleteReason(), accessToken, refreshToken); return ResponseEntity.noContent().build(); } @@ -89,40 +141,45 @@ public boolean checkDuplicate( } // 북마크한 서비스 목록 조회 - @GetMapping("/users/me/bookmarked") + @GetMapping("/users/me/bookmarked/posts") public ResponseEntity> getBookmarkedServices( @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(publicServiceHandler.getBookmarkedServices( userDetails.getId(), PageRequest.of(page, size)) ); } - /** - * 작성한 게시글 목록 조회 - */ -// @GetMapping("/me/posts") -// @PreAuthorize("hasRole('USER')") -// public ResponseEntity>> getMyPosts( -// @RequestParam(required = false) String postType, -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { -// Page posts = userService.getMyPosts(postType, PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(posts)); -// } -// - /** - * 작성한 댓글 목록 조회 - */ -// @GetMapping("/me/comments") -// @PreAuthorize("hasRole('USER')") -// public ResponseEntity>> getMyComments( -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { -// Page comments = userService.getMyComments(PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(comments)); -// } + /** + * 내가 작성한 게시글 목록 조회 + */ + @GetMapping("/users/me/posts") + public ResponseEntity> getMyPosts( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + Page posts = postService.getPostsByUserId( + userDetails.getId(), PageRequest.of(page, size)); + + return ResponseEntity.ok(posts); + } + + /** + * 내가 추천한 게시글 목록 조회 + */ + @GetMapping("/users/me/recommended/posts") + public ResponseEntity> getMyRecommendedPosts( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "10") @Positive @Max(30) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + Page posts = postService.getRecommendedPostsByUserId( + userDetails.getId(), PageRequest.of(page, size)); + + return ResponseEntity.ok(posts); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java new file mode 100644 index 0000000..615a7e5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserReportController.java @@ -0,0 +1,29 @@ +package com.hyetaekon.hyetaekon.user.controller; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.user.dto.UserReportRequestDto; +import com.hyetaekon.hyetaekon.user.service.UserReportService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users/reports") +@RequiredArgsConstructor +public class UserReportController { + private final UserReportService userReportService; + + @PostMapping + public ResponseEntity reportUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UserReportRequestDto reportRequestDto + ) { + Long reporterId = userDetails.getId(); + userReportService.reportUser(reporterId, reportRequestDto); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java new file mode 100644 index 0000000..5873589 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDeleteRequestDto.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserDeleteRequestDto { + private String deleteReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java new file mode 100644 index 0000000..bddd983 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserReportRequestDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserReportRequestDto { + @NotNull(message = "신고 대상 사용자 ID는 필수입니다.") + private Long reportedUserId; + + @NotNull(message = "신고 사유는 필수입니다.") + private String reason; + + private String content; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java new file mode 100644 index 0000000..fe214ae --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportProcessDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.user.dto.admin; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserReportProcessDto { + private boolean suspendUser; // 신고 처리 시 사용자 정지 여부 + + // 사용자 정지시 필요한 정보 (suspendUser가 true일 때) + private LocalDateTime suspendStartAt; + private LocalDateTime suspendEndAt; + private String suspendReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java index 2a07a24..9c02ed9 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java @@ -5,7 +5,7 @@ @Getter public enum PointActionType { POST_CREATION(20), // 게시글 작성 (20점) - FIRST_POST_CREATION(100), // 첫 게시글 작성 (100점) + FIRST_GREETING_POST_CREATION(100), // 인사 게시글 작성 (100점) ANSWER_CREATION(10), // 답변 작성 (10점) ANSWER_ACCEPTED(50); // 답변이 채택됨 (50점) diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java new file mode 100644 index 0000000..34ba972 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/ReportStatus.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.user.entity; + +import lombok.Getter; + +@Getter +public enum ReportStatus { + PENDING("처리 대기중"), + RESOLVED("처리 완료"), + REJECTED("거부됨"); + + private final String description; + + ReportStatus(String description) { + this.description = description; + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java index babb41b..390c657 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.*; @@ -18,6 +18,7 @@ @NoArgsConstructor @AllArgsConstructor public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java index b63b3ac..9f9c66d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java @@ -35,11 +35,22 @@ public class UserReport { private String content; @Column(name = "status", length = 20) - private String status; + @Enumerated(EnumType.STRING) + private ReportStatus status; @Column(name = "created_at") private LocalDateTime createdAt; @Column(name = "processed_at") private LocalDateTime processedAt; + + public void resolve() { + this.status = ReportStatus.RESOLVED; + this.processedAt = LocalDateTime.now(); + } + + public void reject() { + this.status = ReportStatus.REJECTED; + this.processedAt = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java index 7d2b56b..0318822 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java @@ -18,5 +18,6 @@ public interface UserAdminMapper { // UserReport Entity -> 신고 내역 DTO 변환 @Mapping(source = "reporter.nickname", target = "reporterNickname") @Mapping(source = "reported.nickname", target = "reportedNickname") + @Mapping(source = "status.description", target = "status") UserReportResponseDto toReportResponseDto(UserReport userReport); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java index 4a99e23..4302435 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java @@ -1,11 +1,15 @@ package com.hyetaekon.hyetaekon.user.repository; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; import com.hyetaekon.hyetaekon.user.entity.UserReport; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface UserReportRepository extends JpaRepository { - // 기본 CRUD 메서드 사용 + // 상태별 신고 목록 조회 + Page findByStatus(ReportStatus status, Pageable pageable); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java index 2c7c20d..63a7de3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java @@ -3,6 +3,7 @@ import com.hyetaekon.hyetaekon.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +17,10 @@ public interface UserRepository extends JpaRepository { // user id로 검색 Optional findByIdAndDeletedAtIsNull(Long id); + @EntityGraph(attributePaths = {"interests"}) + @Query("SELECT u FROM User u WHERE u.id = :id AND u.deletedAt IS NULL") + Optional findByIdAndDeletedAtIsNullWithInterests(@Param("id") Long id); + // user realId로 검색 Optional findByRealIdAndDeletedAtIsNull(String realId); diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java index 5a262a3..8f7be13 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java @@ -26,6 +26,7 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final UserService userService; private final BlacklistService blacklistService; + private final CookieUtil cookieUtil; // 로그인 @Transactional @@ -59,7 +60,7 @@ public JwtToken refresh(String refreshToken) { public void logout(String accessToken, String refreshToken, HttpServletResponse response) { blacklistService.addToBlacklist(accessToken); refreshTokenService.deleteRefreshToken(refreshToken); - CookieUtil.deleteCookie(response, "refreshToken"); + cookieUtil.deleteCookie(response, "refreshToken"); } // realId, password를 사용해서 유저 확인 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/UserPointService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java index 2dd70db..f55529d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java @@ -2,6 +2,7 @@ import com.hyetaekon.hyetaekon.common.exception.ErrorCode; import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.repository.PostRepository; import com.hyetaekon.hyetaekon.user.entity.PointActionType; import com.hyetaekon.hyetaekon.user.entity.User; @@ -17,7 +18,7 @@ public class UserPointService { private final UserRepository userRepository; private final UserLevelService userLevelService; - private final PostRepository postRepository; // 게시글 저장소 추가 필요 + private final PostRepository postRepository; @Transactional public void addPointForAction(Long userId, PointActionType actionType) { @@ -26,21 +27,20 @@ public void addPointForAction(Long userId, PointActionType actionType) { int pointToAdd = actionType.getPoints(); - // 게시글 작성인 경우 첫 게시글 여부 확인 - if (actionType == PointActionType.POST_CREATION) { - boolean isFirstPost = !postRepository.existsByUser_IdAndDeletedAtIsNull(userId); - if (isFirstPost) { - // 첫 게시글인 경우 FIRST_POST_CREATION 포인트 적용 - pointToAdd = PointActionType.FIRST_POST_CREATION.getPoints(); - log.info("사용자 {}의 첫 게시글 작성으로 {}점 획득", userId, pointToAdd); - } - } + // 현재 포인트 로깅 + log.info("포인트 부여 전 - 사용자 ID: {}, 현재 포인트: {}, 부여할 포인트: {}", + userId, user.getPoint(), pointToAdd); user.addPoint(pointToAdd); // 레벨 체크 및 업데이트 userLevelService.checkAndUpdateLevel(user); - userRepository.save(user); + User savedUser = userRepository.save(user); + + // 포인트 부여 후 로깅 + log.info("포인트 부여 후 - 사용자 ID: {}, 최종 포인트: {}, 레벨: {}", + userId, savedUser.getPoint(), savedUser.getLevel().getName()); } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java new file mode 100644 index 0000000..56e44f4 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserReportService.java @@ -0,0 +1,54 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.user.dto.UserReportRequestDto; +import com.hyetaekon.hyetaekon.user.entity.ReportStatus; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserReport; +import com.hyetaekon.hyetaekon.user.repository.UserReportRepository; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserReportService { + private final UserRepository userRepository; + private final UserReportRepository userReportRepository; + + // 사용자 신고 + @Transactional + public void reportUser(Long reporterId, UserReportRequestDto reportRequestDto) { + // 신고자 확인 + User reporter = userRepository.findByIdAndDeletedAtIsNull(reporterId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 신고 대상자 확인 + User reported = userRepository.findByIdAndDeletedAtIsNull(reportRequestDto.getReportedUserId()) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 자기 자신 신고 방지 + if (reporter.getId().equals(reported.getId())) { + throw new GlobalException(ErrorCode.CANNOT_REPORT_SELF); + } + + // 신고 내역 생성 및 저장 + UserReport userReport = UserReport.builder() + .reporter(reporter) + .reported(reported) + .reason(reportRequestDto.getReason()) + .content(reportRequestDto.getContent()) + .status(ReportStatus.PENDING) // 대기 상태로 초기화 + .createdAt(LocalDateTime.now()) + .build(); + + userReportRepository.save(userReport); + log.info("사용자 신고 접수 완료 - 신고자: {}, 피신고자: {}", reporter.getId(), reported.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java index 6c47b6c..b771a41 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java @@ -97,6 +97,14 @@ public User findUserByRealId(String realId) { } + @Transactional(readOnly = true) + public UserResponseDto getUserById(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + return userMapper.toResponseDto(user); + } + // 회원 정보 수정(닉네임, 이름, 성별, 생년월일, 지역, 직업) @Transactional 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 87% 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..fb696c2 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.*; @@ -17,7 +17,7 @@ public class UserInterest { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java similarity index 89% rename from src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java rename to src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java index 92f8296..a738203 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/entity/UserInterestEnum.java @@ -1,4 +1,4 @@ -package com.hyetaekon.hyetaekon.UserInterest.entity; +package com.hyetaekon.hyetaekon.userInterest.entity; import lombok.Getter; @@ -13,6 +13,8 @@ public enum UserInterestEnum { HEALTH_MEDICAL("보건·의료", "관심주제"), CULTURE_ENVIRONMENT("문화·환경", "관심주제"), LIFE_STABILITY("생활안정", "관심주제"), + PROTECTION_CARE("보호·돌봄", "관심주제"), // 새로 추가된 카테고리 + OTHER("기타", "관심주제"), // SpecialGroup 관련 관심사 IS_MULTI_CULTURAL("다문화가족", "가구형태"), diff --git a/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java new file mode 100644 index 0000000..7248ddc --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/userInterest/repository/UserInterestRepository.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.userInterest.repository; + +import com.hyetaekon.hyetaekon.userInterest.entity.UserInterest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserInterestRepository extends JpaRepository { + /** + * 사용자 ID에 해당하는 모든 관심사 항목을 조회 + */ + List findByUserId(Long userId); + + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java b/src/main/java/com/hyetaekon/hyetaekon/userInterest/service/UserInterestService.java similarity index 77% 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..ff459c2 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; @@ -27,7 +27,7 @@ public class UserInterestService { // 모든 관심사 목록과 사용자 선택 여부 함께 조회 @Transactional(readOnly = true) public CategorizedInterestsWithSelectionDto getUserInterestsWithSelection(Long userId) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) + User user = userRepository.findByIdAndDeletedAtIsNullWithInterests(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); // 사용자가 선택한 관심사 목록 @@ -96,19 +96,4 @@ public void saveUserInterests(Long userId, List selectedInterests) { log.debug("회원 관심사 갱신 - 유저 ID: {}, 선택 관심사: {}", userId, selectedInterests); } - /* // 나의 관심사 조회 - @Transactional(readOnly = true) - public UserInterestResponseDto getUserInterestsByUserId(Long userId) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - - List userInterests = user.getInterests().stream() - .map(UserInterest::getInterest) - .toList(); - - log.debug("회원 관심사 조회 - 유저 ID: {}, 관심사: {}", userId, userInterests); - return new UserInterestResponseDto(userInterests); - } - - */ } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6ccf93e..95b73ab 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -7,5 +7,9 @@ spring: jwt: access-expired: 3600 # 1시간 - refresh-expired: 86400 # 1일 + refresh-expired: 7200 # 2시간 +app: + cookie: + secure: false + same-site: Lax \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7259009..d5ff88b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,3 +1,6 @@ +server: + forward-headers-strategy: framework + spring: datasource: url: ${MYSQL_PROD_URL} # MySQL 데이터베이스 URL @@ -9,4 +12,9 @@ spring: jwt: access-expired: 1800 # 30분 - refresh-expired: 432000 # 5일 \ No newline at end of file + refresh-expired: 86400 # 1일 + +app: + cookie: + secure: true + same-site: None \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 315d5b6..7fc6fde 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: data: -# mongodb: -# uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&appName=HyetaekOn -# auto-index-creation: true + mongodb: + uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&serverSelectionTimeoutMS=30000&appName=HyetaekOn + auto-index-creation: true redis: port: 6379 timeout: 2000