From 22f41c99a3084f35cfc5e262e7ff12508a765f6e Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Fri, 10 Oct 2025 15:54:58 +0900 Subject: [PATCH 1/9] feat[post]:myvotedpaged --- .../poll/controller/PollController.java | 6 +- .../poll/repository/PollVoteRepository.java | 2 + .../domain/poll/service/PollService.java | 2 +- .../domain/poll/service/PollServiceImpl.java | 26 ++-- .../post/controller/PostController.java | 113 ++++++++---------- .../domain/post/service/PostService.java | 3 + .../domain/post/service/PostServiceImpl.java | 43 +++++-- .../com/ai/lawyer/global/util/AuthUtil.java | 39 ++++++ .../poll/controller/PollControllerTest.java | 2 +- .../domain/poll/service/PollServiceTest.java | 6 +- .../post/controller/PostControllerTest.java | 77 ++++++++++++ 11 files changed, 227 insertions(+), 92 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index 66e9ec9..6c34b1b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java @@ -75,11 +75,7 @@ public ResponseEntity> updatePoll(@PathVariable Long pollId @DeleteMapping("/{pollId}") public ResponseEntity> deletePoll(@PathVariable Long pollId) { Long currentMemberId = AuthUtil.getCurrentMemberId(); - PollDto poll = pollService.getPoll(pollId, currentMemberId); - if (!poll.getPostId().equals(currentMemberId)) { - return ResponseEntity.status(403).body(new ApiResponse<>(403, "본인만 투표를 삭제할 수 있습니다.", null)); - } - pollService.deletePoll(pollId); + pollService.deletePoll(pollId, currentMemberId); return ResponseEntity.ok(new ApiResponse<>(200, "투표가 삭제되었습니다.", null)); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index 4ac9477..a2a6138 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -3,10 +3,12 @@ import com.ai.lawyer.domain.poll.entity.PollVote; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface PollVoteRepository extends JpaRepository, PollVoteRepositoryCustom { Optional findByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); Optional findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); + List findByMember_MemberId(Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 97d79da..ebe9f03 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -34,7 +34,7 @@ public interface PollService { PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId); void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); void closePoll(Long pollId); - void deletePoll(Long pollId); + void deletePoll(Long pollId, Long memberId); // ===== 검증 관련 ===== void validatePollCreate(PollCreateDto dto); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 32ffb01..feeb932 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -4,11 +4,12 @@ import com.ai.lawyer.domain.poll.repository.*; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,7 @@ import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto; import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto; +import com.ai.lawyer.global.util.AuthUtil; @Service @Transactional @@ -42,7 +44,6 @@ public class PollServiceImpl implements PollService { private final PollOptionsRepository pollOptionsRepository; private final PollVoteRepository pollVoteRepository; private final PollStaticsRepository pollStaticsRepository; - private final MemberRepository memberRepository; private final PostRepository postRepository; @Override @@ -51,8 +52,7 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID는 필수입니다."); } validatePollCommon(request.getVoteTitle(), request.getPollOptions(), request.getReservedCloseAt()); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = postRepository.findById(request.getPostId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); if (post.getPoll() != null) { @@ -117,8 +117,7 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { } PollOptions pollOptions = pollOptionsRepository.findById(pollItemsId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표 항목을 찾을 수 없습니다.")); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); // USER 또는 ADMIN만 투표 가능 if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); @@ -190,7 +189,7 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder() .option(option) .ageGroup(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .voteCount(arr[2] != null ? ((Number) arr[2]).longValue() : 0L) .build(); ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } @@ -214,7 +213,7 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder() .option(option) .gender(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .voteCount(arr[2] != null ? ((Number) arr[2]).longValue() : 0L) .build(); genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } @@ -246,10 +245,12 @@ public void closePoll(Long pollId) { } @Override - public void deletePoll(Long pollId) { + public void deletePoll(Long pollId, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - + if (poll.getPost() == null || !poll.getPost().getMember().getMemberId().equals(memberId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 삭제할 수 있습니다."); + } // 1. 이 Poll을 참조하는 Post가 있으면 연결 해제 Post post = postRepository.findAll().stream() .filter(p -> p.getPoll() != null && p.getPoll().getPollId().equals(pollId)) @@ -259,7 +260,6 @@ public void deletePoll(Long pollId) { post.setPoll(null); postRepository.save(post); } - // 2. Poll 삭제 pollRepository.deleteById(pollId); } @@ -458,12 +458,12 @@ private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { statics = staticsRaw.stream() .map(arr -> { String gender = arr[1] != null ? arr[1].toString() : null; - Integer age = arr[2] != null ? ((Number)arr[2]).intValue() : null; + Integer age = arr[2] != null ? ((Number) arr[2]).intValue() : null; String ageGroup = getAgeGroup(age); return PollStaticsDto.builder() .gender(gender) .ageGroup(ageGroup) - .voteCount((Long)arr[3]) + .voteCount((Long) arr[3]) .build(); }).toList(); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index e8bf31d..e13ec6c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -36,16 +36,7 @@ public class PostController { @Operation(summary = "게시글 등록") @PostMapping public ResponseEntity> createPost(@RequestBody PostRequestDto postRequestDto) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDto created = postService.createPost(postRequestDto, memberId); return ResponseEntity.ok(new ApiResponse<>(201, "게시글이 등록되었습니다.", created)); } @@ -91,43 +82,31 @@ public ResponseEntity>> getPostsByMember(@PathVa @Operation(summary = "게시글 수정") @PutMapping("/{postId}") public ResponseEntity> updatePost(@PathVariable Long postId, @RequestBody PostUpdateDto postUpdateDto) { - Long currentMemberId = AuthUtil.getCurrentMemberId(); - String currentRole = AuthUtil.getCurrentMemberRole(); - PostDetailDto postDetail = postService.getPostDetailById(postId, currentMemberId); + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); Long postOwnerId = postDetail.getPost().getMemberId(); - if (!postOwnerId.equals(currentMemberId) && !"ADMIN".equals(currentRole)) { - return ResponseEntity.status(403).body(new ApiResponse<>(403, "본인 또는 관리자만 수정 가능합니다.", null)); - } + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.updatePost(postId, postUpdateDto); - PostDetailDto updated = postService.getPostDetailById(postId, currentMemberId); + PostDetailDto updated = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 수정되었습니다.", updated)); } @Operation(summary = "게시글 부분 수정(PATCH)") @PatchMapping("/{postId}") public ResponseEntity> patchUpdatePost(@PathVariable Long postId, @RequestBody PostUpdateDto postUpdateDto) { - Long currentMemberId = AuthUtil.getCurrentMemberId(); - String currentRole = AuthUtil.getCurrentMemberRole(); - PostDetailDto postDetail = postService.getPostDetailById(postId, currentMemberId); + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); Long postOwnerId = postDetail.getPost().getMemberId(); - if (!postOwnerId.equals(currentMemberId) && !"ADMIN".equals(currentRole)) { - return ResponseEntity.status(403).body(new ApiResponse<>(403, "본인 또는 관리자만 수정 가능합니다.", null)); - } + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.patchUpdatePost(postId, postUpdateDto); - PostDetailDto updated = postService.getPostDetailById(postId, currentMemberId); + PostDetailDto updated = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 수정되었습니다.", updated)); } @Operation(summary = "게시글 삭제") @DeleteMapping("/{postId}") public ResponseEntity> deletePost(@PathVariable Long postId) { - Long currentMemberId = AuthUtil.getCurrentMemberId(); - String currentRole = AuthUtil.getCurrentMemberRole(); - PostDetailDto postDetail = postService.getPostDetailById(postId, currentMemberId); + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); Long postOwnerId = postDetail.getPost().getMemberId(); - if (!postOwnerId.equals(currentMemberId) && !"ADMIN".equals(currentRole)) { - return ResponseEntity.status(403).body(new ApiResponse<>(403, "본인 또는 관리자만 삭제 가능합니다.", null)); - } + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.deletePost(postId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); } @@ -142,16 +121,7 @@ public ResponseEntity> handleResponseStatusException(ResponseS @Operation(summary = "본인 게시글 단일 조회") @GetMapping("/my/{postId}") public ResponseEntity> getMyPostById(@PathVariable Long postId) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDto postDto = postService.getMyPostById(postId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 단일 조회 성공", postDto)); } @@ -159,16 +129,7 @@ public ResponseEntity> getMyPostById(@PathVariable Long pos @Operation(summary = "본인 게시글 전체 조회") @GetMapping("/my") public ResponseEntity>> getMyPosts() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); List posts = postService.getMyPosts(memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", posts)); } @@ -176,16 +137,7 @@ public ResponseEntity>> getMyPosts() { @Operation(summary = "게시글+투표 동시 등록") @PostMapping("/createPost") public ResponseEntity> createPostWithPoll(@RequestBody PostWithPollCreateDto dto) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다."); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDetailDto result = postService.createPostWithPoll(dto, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글+투표 등록 완료", result)); } @@ -271,4 +223,43 @@ public ResponseEntity> getTopClosedPoll() { PostDto post = postService.getTopPollByStatus(PollDto.PollStatus.CLOSED, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "마감된 투표 Top 1 조회 성공", post)); } -} \ No newline at end of file + + @Operation(summary = "내가 참여한 진행중 투표 게시글 페이징 조회") + @GetMapping("/my/ongoingPaged") + public ResponseEntity> getMyOngoingPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page posts = postService.getMyOngoingPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(posts); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 진행중 투표 게시글 페이징 조회 성공", response)); + } + + @Operation(summary = "내가 참여한 마감 투표 게시글 페이징 조회") + @GetMapping("/my/closedPaged") + public ResponseEntity> getMyClosedPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page posts = postService.getMyClosedPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(posts); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 마감 투표 게시글 페이징 조회 성공", response)); + } + + @Operation(summary = "내가 참여한 모든 투표 게시글 페이징 조회") + @GetMapping("/my/votedPaged") + public ResponseEntity> getMyVotedPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page posts = postService.getMyVotedPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(posts); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 모든 투표 게시글 페이징 조회 성공", response)); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index a2ed98b..2988846 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -36,6 +36,9 @@ public interface PostService { Page getPostsPaged(Pageable pageable, Long memberId); Page getOngoingPostsPaged(Pageable pageable, Long memberId); Page getClosedPostsPaged(Pageable pageable, Long memberId); + Page getMyOngoingPostsPaged(Pageable pageable, Long memberId); + Page getMyClosedPostsPaged(Pageable pageable, Long memberId); + Page getMyVotedPostsPaged(Pageable pageable, Long memberId); // ===== 투표 Top 관련 ===== List getTopNPollsByStatus(PollDto.PollStatus status, int n, Long memberId); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index cd27542..1e85a4a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -2,6 +2,7 @@ import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; @@ -19,6 +20,7 @@ import com.ai.lawyer.domain.poll.entity.PollOptions; import com.ai.lawyer.domain.poll.repository.PollVoteRepository; import com.ai.lawyer.domain.poll.service.PollService; +import com.ai.lawyer.global.util.AuthUtil; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.PageImpl; @@ -62,8 +64,7 @@ public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); } - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() .member(member) .postName(postRequestDto.getPostName()) @@ -96,8 +97,7 @@ public PostDetailDto getPostById(Long postId) { @Override public List getPostsByMemberId(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); List posts = postRepository.findByMember(member); if (posts.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); @@ -164,8 +164,7 @@ public PostDto getMyPostById(Long postId, Long requesterMemberId) { } public List getMyPosts(Long requesterMemberId) { - Member member = memberRepository.findById(requesterMemberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(requesterMemberId); List posts = postRepository.findByMember(member); // 본인 게시글이 없으면 빈 리스트 반환 return posts.stream() @@ -210,8 +209,7 @@ public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId } var pollDto = dto.getPoll(); pollService.validatePollCreate(pollDto); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() .member(member) .postName(postDto.getPostName()) @@ -326,4 +324,33 @@ private PostDto convertToDto(Post entity, Long memberId) { .poll(pollDto) .build(); } + + private Page getMyVotedPostsPagedByStatus(Pageable pageable, Long memberId, Poll.PollStatus status) { + List votes = pollVoteRepository.findByMember_MemberId(memberId); + List pollIds = votes.stream().map(v -> v.getPoll().getPollId()).distinct().toList(); + List posts = postRepository.findAll().stream() + .filter(p -> p.getPoll() != null && pollIds.contains(p.getPoll().getPollId()) + && (status == null || p.getPoll().getStatus() == status)) + .toList(); + List postDtos = posts.stream().map(p -> convertToDto(p, memberId)).toList(); + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), postDtos.size()); + List paged = start < end ? postDtos.subList(start, end) : List.of(); + return new org.springframework.data.domain.PageImpl<>(paged, pageable, postDtos.size()); + } + + @Override + public Page getMyVotedPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, null); + } + + @Override + public Page getMyOngoingPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, Poll.PollStatus.ONGOING); + } + + @Override + public Page getMyClosedPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, Poll.PollStatus.CLOSED); + } } diff --git a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java index b41e07b..aa8a7a6 100644 --- a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java @@ -3,8 +3,22 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.entity.Member; +@Component public class AuthUtil { + private static MemberRepository memberRepository; + + @Autowired + public AuthUtil(MemberRepository memberRepository) { + AuthUtil.memberRepository = memberRepository; + } + public static Long getCurrentMemberId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { @@ -40,4 +54,29 @@ public static String getCurrentMemberRole() { .orElse(null); } + public static Member getMemberOrThrow(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); + } + + public static Long getAuthenticatedMemberId() { + try { + Long memberId = getCurrentMemberId(); + if (memberId == null) { + throw new IllegalArgumentException(); + } + return memberId; + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다"); + } + } + + public static void validateOwnerOrAdmin(Long ownerId) { + Long currentMemberId = getAuthenticatedMemberId(); + String currentRole = getCurrentMemberRole(); + if (!ownerId.equals(currentMemberId) && !"ADMIN".equals(currentRole)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인 또는 관리자만 수정 가능합니다."); + } + } + } diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index 7425db9..ce8b037 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -114,7 +114,7 @@ void t4() throws Exception { void t5() throws Exception { PollDto pollDto = PollDto.builder().pollId(1L).postId(1L).build(); Mockito.when(pollService.getPoll(Mockito.eq(1L), Mockito.anyLong())).thenReturn(pollDto); - Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong()); + Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong(), Mockito.anyLong()); mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/api/polls/1") diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java index 1f986f6..888b2b1 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java @@ -72,9 +72,9 @@ void t5() { @Test @DisplayName("투표 삭제") void t6() { - Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong()); - pollService.deletePoll(1L); - Mockito.verify(pollService).deletePoll(1L); + Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong(), Mockito.anyLong()); + pollService.deletePoll(1L, 1L); + Mockito.verify(pollService).deletePoll(1L, 1L); } @Test diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java index 22d0fbc..05ef3d0 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java @@ -187,4 +187,81 @@ void t7() throws Exception { .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalPages").value(1)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalElements").value(1)); } + + @Test + @DisplayName("게시글 간편 전체 조회") + void t8() throws Exception { + List posts = java.util.Collections.emptyList(); + Mockito.when(postService.getAllSimplePosts()).thenReturn(posts); + mockMvc.perform(get("/api/posts/simplePost") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); + } + + @Test + @DisplayName("본인 게시글 단일 조회") + void t9() throws Exception { + com.ai.lawyer.domain.post.dto.PostDto postDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build(); + Mockito.when(postService.getMyPostById(Mockito.eq(1L), Mockito.anyLong())).thenReturn(postDto); + mockMvc.perform(get("/api/posts/my/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.postId").value(1L)); + } + + @Test + @DisplayName("본인 게시글 전체 조회") + void t10() throws Exception { + List posts = java.util.Collections.emptyList(); + Mockito.when(postService.getMyPosts(Mockito.anyLong())).thenReturn(posts); + mockMvc.perform(get("/api/posts/my") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); + } + + @Test + @DisplayName("게시글+투표 동시 등록") + void t11() throws Exception { + com.ai.lawyer.domain.post.dto.PostDetailDto result = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post( + com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build() + ).build(); + com.ai.lawyer.domain.post.dto.PostWithPollCreateDto dto = com.ai.lawyer.domain.post.dto.PostWithPollCreateDto.builder().build(); + Mockito.when(postService.createPostWithPoll(Mockito.any(), Mockito.anyLong())).thenReturn(result); + mockMvc.perform(post("/api/posts/createPost") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto)) + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.post.postId").value(1L)); + } + + @Test + @DisplayName("진행중 투표 게시글 페이징 조회") + void t12() throws Exception { + org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); + org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(java.util.List.of(), pageable, 0); + Mockito.when(postService.getOngoingPostsPaged(Mockito.any(), Mockito.anyLong())).thenReturn(page); + mockMvc.perform(get("/api/posts/ongoingPaged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.content").isArray()); + } + + @Test + @DisplayName("마감 투표 게시글 페이징 조회") + void t13() throws Exception { + org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); + org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(java.util.List.of(), pageable, 0); + Mockito.when(postService.getClosedPostsPaged(Mockito.any(), Mockito.anyLong())).thenReturn(page); + mockMvc.perform(get("/api/posts/closedPaged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.content").isArray()); + } } \ No newline at end of file From baecebd9f864a56bd5fbcca8cb841d7e65a5e788 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Fri, 10 Oct 2025 17:03:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix[member]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/domain/member/entity/Member.java | 18 ++++++++++++++++++ .../domain/member/entity/OAuth2Member.java | 18 ++++++++++++++++++ .../domain/member/service/MemberService.java | 16 +++++++++++++--- .../member/service/MemberServiceTest.java | 3 ++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index 54c113c..bee29c2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -1,5 +1,8 @@ package com.ai.lawyer.domain.member.entity; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -7,6 +10,8 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "member", @@ -63,6 +68,19 @@ public class Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; + // 연관 관계: 회원 탈퇴 시 cascade 삭제 + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List pollVotes = new ArrayList<>(); + + @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List histories = new ArrayList<>(); + @Getter public enum Gender { MALE("남성"), FEMALE("여성"), OTHER("기타"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java index 2217d3a..754c752 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java @@ -1,5 +1,8 @@ package com.ai.lawyer.domain.member.entity; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -7,6 +10,8 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "oauth2_member", @@ -74,6 +79,19 @@ public class OAuth2Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; + // 연관 관계: 회원 탈퇴 시 cascade 삭제 + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List pollVotes = new ArrayList<>(); + + @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List histories = new ArrayList<>(); + @Getter public enum Provider { KAKAO("카카오"), NAVER("네이버"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 0806e9b..8361b12 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -186,11 +186,21 @@ public String getLoginIdByMemberId(Long memberId) { @Transactional public void deleteMember(String loginId) { - // Member 또는 OAuth2Member 삭제 + log.info("회원 탈퇴 시작: loginId={}", loginId); + + // 1. Redis 토큰 삭제 + try { + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); + } catch (Exception e) { + log.error("Redis 토큰 삭제 실패: loginId={}, error={}", loginId, e.getMessage()); + } + + // 2. Member 또는 OAuth2Member 삭제 (cascade로 연관 데이터 자동 삭제) java.util.Optional regularMember = memberRepository.findByLoginId(loginId); if (regularMember.isPresent()) { memberRepository.delete(regularMember.get()); - log.info("일반 회원 삭제 완료: loginId={}", loginId); + log.info("일반 회원 삭제 완료 (연관 데이터 cascade 삭제): loginId={}", loginId); return; } @@ -198,7 +208,7 @@ public void deleteMember(String loginId) { java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); if (oauth2Member.isPresent()) { oauth2MemberRepository.delete(oauth2Member.get()); - log.info("OAuth2 회원 삭제 완료: loginId={}", loginId); + log.info("OAuth2 회원 삭제 완료 (연관 데이터 cascade 삭제): loginId={}", loginId); return; } } diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 1c06e93..655e5ce 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -300,8 +300,9 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then + verify(tokenProvider).deleteAllTokens(loginId); // Redis 토큰 삭제 verify(memberRepository).findByLoginId(loginId); - verify(memberRepository).delete(member); + verify(memberRepository).delete(member); // 회원 삭제 (cascade로 연관 데이터 자동 삭제) } @Test From 5fb21e819b6a12a0ebe412ce87c4bfd6bbcf06c9 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Fri, 10 Oct 2025 17:03:18 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix[member]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lawyer/domain/chatbot/entity/History.java | 2 +- .../chatbot/repository/HistoryRepository.java | 11 +++ .../lawyer/domain/member/entity/Member.java | 18 ++++ .../domain/member/entity/OAuth2Member.java | 18 ++++ .../domain/member/service/MemberService.java | 91 ++++++++++++++++--- .../poll/repository/PollVoteRepository.java | 11 +++ .../post/repository/PostRepository.java | 11 +++ .../member/service/MemberServiceTest.java | 3 +- 8 files changed, 152 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java index 16eb6cc..fb0f1a2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java @@ -25,7 +25,7 @@ public class History { private Long historyId; @ManyToOne - @JoinColumn(name = "member_id") + @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "FK_HISTORY_MEMBER")) private Member memberId; @OneToMany(mappedBy = "historyId", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java index 3082825..ec094d4 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java @@ -3,6 +3,9 @@ import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -14,4 +17,12 @@ public interface HistoryRepository extends JpaRepository { History findByHistoryIdAndMemberId(Long roomId, Member memberId); + /** + * member_id로 채팅 히스토리 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM History h WHERE h.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); + } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index 54c113c..bee29c2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -1,5 +1,8 @@ package com.ai.lawyer.domain.member.entity; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -7,6 +10,8 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "member", @@ -63,6 +68,19 @@ public class Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; + // 연관 관계: 회원 탈퇴 시 cascade 삭제 + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List pollVotes = new ArrayList<>(); + + @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List histories = new ArrayList<>(); + @Getter public enum Gender { MALE("남성"), FEMALE("여성"), OTHER("기타"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java index 2217d3a..754c752 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java @@ -1,5 +1,8 @@ package com.ai.lawyer.domain.member.entity; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -7,6 +10,8 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "oauth2_member", @@ -74,6 +79,19 @@ public class OAuth2Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; + // 연관 관계: 회원 탈퇴 시 cascade 삭제 + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List pollVotes = new ArrayList<>(); + + @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List histories = new ArrayList<>(); + @Getter public enum Provider { KAKAO("카카오"), NAVER("네이버"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 0806e9b..e2f6f62 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -5,6 +5,9 @@ import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import com.ai.lawyer.domain.post.repository.PostRepository; +import com.ai.lawyer.domain.poll.repository.PollVoteRepository; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.email.service.EmailService; @@ -27,6 +30,9 @@ public class MemberService { private final CookieUtil cookieUtil; private final EmailService emailService; private final EmailAuthService emailAuthService; + private final PostRepository postRepository; + private final PollVoteRepository pollVoteRepository; + private final HistoryRepository historyRepository; public MemberService( MemberRepository memberRepository, @@ -34,13 +40,19 @@ public MemberService( TokenProvider tokenProvider, CookieUtil cookieUtil, EmailService emailService, - EmailAuthService emailAuthService) { + EmailAuthService emailAuthService, + PostRepository postRepository, + PollVoteRepository pollVoteRepository, + HistoryRepository historyRepository) { this.memberRepository = memberRepository; this.passwordEncoder = passwordEncoder; this.tokenProvider = tokenProvider; this.cookieUtil = cookieUtil; this.emailService = emailService; this.emailAuthService = emailAuthService; + this.postRepository = postRepository; + this.pollVoteRepository = pollVoteRepository; + this.historyRepository = historyRepository; } @org.springframework.beans.factory.annotation.Autowired(required = false) @@ -186,24 +198,81 @@ public String getLoginIdByMemberId(Long memberId) { @Transactional public void deleteMember(String loginId) { - // Member 또는 OAuth2Member 삭제 + log.info("회원 탈퇴 시작: loginId={}", loginId); + + // 1. Member 또는 OAuth2Member 조회하여 memberId 가져오기 + Long memberId = null; + boolean isRegularMember = false; + java.util.Optional regularMember = memberRepository.findByLoginId(loginId); if (regularMember.isPresent()) { - memberRepository.delete(regularMember.get()); - log.info("일반 회원 삭제 완료: loginId={}", loginId); + memberId = regularMember.get().getMemberId(); + isRegularMember = true; + log.info("일반 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } else if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + if (oauth2Member.isPresent()) { + memberId = oauth2Member.get().getMemberId(); + log.info("OAuth2 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } + } + + if (memberId == null) { + log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); return; } - if (oauth2MemberRepository != null) { + // 2. 연관된 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) + log.info("연관 데이터 삭제 시작: memberId={}", memberId); + + // 2-1. 채팅 히스토리 삭제 (Chat 엔티티도 cascade로 함께 삭제됨) + try { + historyRepository.deleteByMemberIdValue(memberId); + log.info("채팅 히스토리 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("채팅 히스토리 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 2-2. 투표 내역 삭제 + try { + pollVoteRepository.deleteByMemberIdValue(memberId); + log.info("투표 내역 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("투표 내역 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 2-3. 게시글 삭제 (Poll 엔티티도 cascade로 함께 삭제됨) + try { + postRepository.deleteByMemberIdValue(memberId); + log.info("게시글 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("게시글 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 3. Redis 토큰 삭제 + try { + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); + } catch (Exception e) { + log.error("Redis 토큰 삭제 실패: loginId={}, error={}", loginId, e.getMessage()); + } + + // 4. 회원 정보 삭제 + final Long finalMemberId = memberId; + if (isRegularMember) { + regularMember.ifPresent(member -> { + memberRepository.delete(member); + log.info("일반 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); + } else if (oauth2MemberRepository != null) { java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); - if (oauth2Member.isPresent()) { - oauth2MemberRepository.delete(oauth2Member.get()); - log.info("OAuth2 회원 삭제 완료: loginId={}", loginId); - return; - } + oauth2Member.ifPresent(member -> { + oauth2MemberRepository.delete(member); + log.info("OAuth2 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); } - log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); + log.info("회원 탈퇴 완료: loginId={}, memberId={}", loginId, finalMemberId); } public void sendCodeToEmailByLoginId(String loginId) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index a2a6138..f86b32b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -2,6 +2,9 @@ import com.ai.lawyer.domain.poll.entity.PollVote; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -11,4 +14,12 @@ public interface PollVoteRepository extends JpaRepository, PollV void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); Optional findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); List findByMember_MemberId(Long memberId); + + /** + * member_id로 투표 내역 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index a69ff05..ba532bf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java @@ -3,6 +3,9 @@ import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.post.entity.Post; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +13,12 @@ @Repository public interface PostRepository extends JpaRepository { List findByMember(Member member); + + /** + * member_id로 게시글 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM Post p WHERE p.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 1c06e93..655e5ce 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -300,8 +300,9 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then + verify(tokenProvider).deleteAllTokens(loginId); // Redis 토큰 삭제 verify(memberRepository).findByLoginId(loginId); - verify(memberRepository).delete(member); + verify(memberRepository).delete(member); // 회원 삭제 (cascade로 연관 데이터 자동 삭제) } @Test From eba261b3a68bf80f4ee88fb8f7626211963a084e Mon Sep 17 00:00:00 2001 From: asowjdan Date: Fri, 10 Oct 2025 17:30:50 +0900 Subject: [PATCH 4/9] =?UTF-8?q?test[member]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberServiceOAuth2Test.java | 14 +++++++++- .../member/service/MemberServiceTest.java | 28 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java index a432edb..36ba2ae 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java @@ -57,6 +57,15 @@ class MemberServiceOAuth2Test { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock private HttpServletResponse response; @@ -74,7 +83,10 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 655e5ce..23e6116 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -52,6 +52,15 @@ class MemberServiceTest { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock private HttpServletResponse response; @@ -73,7 +82,10 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); @@ -300,9 +312,19 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then - verify(tokenProvider).deleteAllTokens(loginId); // Redis 토큰 삭제 + // 1. 회원 조회 verify(memberRepository).findByLoginId(loginId); - verify(memberRepository).delete(member); // 회원 삭제 (cascade로 연관 데이터 자동 삭제) + + // 2. 연관 데이터 명시적 삭제 (순서 중요) + verify(historyRepository).deleteByMemberIdValue(member.getMemberId()); + verify(pollVoteRepository).deleteByMemberIdValue(member.getMemberId()); + verify(postRepository).deleteByMemberIdValue(member.getMemberId()); + + // 3. Redis 토큰 삭제 + verify(tokenProvider).deleteAllTokens(loginId); + + // 4. 회원 삭제 + verify(memberRepository).delete(member); } @Test From 838a7c4c9ce5f1e5a0ce00e0f209bacfa09d7d63 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Fri, 10 Oct 2025 17:41:59 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix[member]:=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=9C=20cas?= =?UTF-8?q?cade=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/domain/member/entity/Member.java | 18 ------------------ .../domain/member/entity/OAuth2Member.java | 18 ------------------ 2 files changed, 36 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index bee29c2..54c113c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -1,8 +1,5 @@ package com.ai.lawyer.domain.member.entity; -import com.ai.lawyer.domain.post.entity.Post; -import com.ai.lawyer.domain.poll.entity.PollVote; -import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -10,8 +7,6 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; @Entity @Table(name = "member", @@ -68,19 +63,6 @@ public class Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; - // 연관 관계: 회원 탈퇴 시 cascade 삭제 - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List posts = new ArrayList<>(); - - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List pollVotes = new ArrayList<>(); - - @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List histories = new ArrayList<>(); - @Getter public enum Gender { MALE("남성"), FEMALE("여성"), OTHER("기타"); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java index 754c752..2217d3a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java @@ -1,8 +1,5 @@ package com.ai.lawyer.domain.member.entity; -import com.ai.lawyer.domain.post.entity.Post; -import com.ai.lawyer.domain.poll.entity.PollVote; -import com.ai.lawyer.domain.chatbot.entity.History; import jakarta.persistence.*; import jakarta.validation.constraints.*; import lombok.*; @@ -10,8 +7,6 @@ import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; @Entity @Table(name = "oauth2_member", @@ -79,19 +74,6 @@ public class OAuth2Member implements MemberAdapter { @Column(name = "updated_at") private LocalDateTime updatedAt; - // 연관 관계: 회원 탈퇴 시 cascade 삭제 - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List posts = new ArrayList<>(); - - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List pollVotes = new ArrayList<>(); - - @OneToMany(mappedBy = "memberId", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List histories = new ArrayList<>(); - @Getter public enum Provider { KAKAO("카카오"), NAVER("네이버"); From 4890f7e327c6ddd292dafccfc760f02ab491b69d Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Fri, 10 Oct 2025 22:21:17 +0900 Subject: [PATCH 6/9] fix[post]:fix paged totalelements --- .../domain/post/service/PostServiceImpl.java | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 1e85a4a..7d556fc 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -266,20 +266,26 @@ public Page getPostsPaged(Pageable pageable, Long memberId) { @Override public Page getOngoingPostsPaged(Pageable pageable, Long memberId) { - Page allPosts = postRepository.findAll(pageable).map(post -> convertToDto(post, memberId)); - List ongoing = allPosts.stream() - .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == PollDto.PollStatus.ONGOING) - .collect(Collectors.toList()); - return new PageImpl<>(ongoing, pageable, ongoing.size()); + List posts = postRepository.findAll().stream() + .filter(p -> p.getPoll() != null && p.getPoll().getStatus() == com.ai.lawyer.domain.poll.entity.Poll.PollStatus.ONGOING) + .toList(); + List postDtos = posts.stream().map(p -> convertToDto(p, memberId)).toList(); + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), postDtos.size()); + List paged = start < end ? postDtos.subList(start, end) : List.of(); + return new PageImpl<>(paged, pageable, postDtos.size()); } @Override public Page getClosedPostsPaged(Pageable pageable, Long memberId) { - Page allPosts = postRepository.findAll(pageable).map(post -> convertToDto(post, memberId)); - List closed = allPosts.stream() - .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == PollDto.PollStatus.CLOSED) - .collect(Collectors.toList()); - return new PageImpl<>(closed, pageable, closed.size()); + List posts = postRepository.findAll().stream() + .filter(p -> p.getPoll() != null && p.getPoll().getStatus() == com.ai.lawyer.domain.poll.entity.Poll.PollStatus.CLOSED) + .toList(); + List postDtos = posts.stream().map(p -> convertToDto(p, memberId)).toList(); + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), postDtos.size()); + List paged = start < end ? postDtos.subList(start, end) : List.of(); + return new PageImpl<>(paged, pageable, postDtos.size()); } @Override @@ -336,7 +342,7 @@ private Page getMyVotedPostsPagedByStatus(Pageable pageable, Long membe int start = (int) pageable.getOffset(); int end = Math.min((start + pageable.getPageSize()), postDtos.size()); List paged = start < end ? postDtos.subList(start, end) : List.of(); - return new org.springframework.data.domain.PageImpl<>(paged, pageable, postDtos.size()); + return new PageImpl<>(paged, pageable, postDtos.size()); } @Override From f04faafa8ffb61a468d6e10f2c894f4c6c6916a3 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Fri, 10 Oct 2025 23:42:59 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat[chat]:=20ai=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ai/lawyer/BackendApplication.java | 2 + .../chatbot/controller/HistoryController.java | 4 +- .../ai/lawyer/domain/chatbot/dto/ChatDto.java | 3 - .../AsyncPostChatProcessingService.java | 132 ++++++++++++++ .../chatbot/service/ChatBotService.java | 166 ++++-------------- .../domain/chatbot/service/ChatService.java | 40 ----- .../chatbot/service/HistoryService.java | 21 +++ 7 files changed, 195 insertions(+), 173 deletions(-) create mode 100644 backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java delete mode 100644 backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java diff --git a/backend/src/main/java/com/ai/lawyer/BackendApplication.java b/backend/src/main/java/com/ai/lawyer/BackendApplication.java index f35a28f..89bfdf4 100644 --- a/backend/src/main/java/com/ai/lawyer/BackendApplication.java +++ b/backend/src/main/java/com/ai/lawyer/BackendApplication.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication @EnableJpaAuditing @ConfigurationPropertiesScan diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java index a18190e..557f528 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java @@ -2,7 +2,6 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; -import com.ai.lawyer.domain.chatbot.service.ChatService; import com.ai.lawyer.domain.chatbot.service.HistoryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -21,7 +20,6 @@ public class HistoryController { private final HistoryService historyService; - private final ChatService chatService; @Operation(summary = "채팅방 제목 목록 조회") @GetMapping("/") @@ -32,7 +30,7 @@ public ResponseEntity> getHistoryTitles(@AuthenticationPrincipa @Operation(summary = "채팅 조회") @GetMapping("/{historyId}") public ResponseEntity> getChatHistory(@AuthenticationPrincipal Long memberId, @PathVariable("historyId") Long roomId) { - return chatService.getChatHistory(memberId, roomId); + return historyService.getChatHistory(memberId, roomId); } @Operation(summary = "채팅방 삭제") diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java index 93095e2..16c2104 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java @@ -34,9 +34,6 @@ public static class ChatResponse { @Schema(description = "채팅방 ID", example = "1") private Long roomId; - @Schema(description = "History 방 제목", example = "손해배상 청구 관련 문의") - private String title; - @Schema(description = "AI 챗봇의 응답 메시지", example = "네, 관련 법령과 판례를 바탕으로 답변해 드리겠습니다.") private String message; diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java new file mode 100644 index 0000000..419371a --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java @@ -0,0 +1,132 @@ +package com.ai.lawyer.domain.chatbot.service; + +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; +import com.ai.lawyer.domain.chatbot.entity.*; +import com.ai.lawyer.domain.chatbot.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.document.Document; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AsyncPostChatProcessingService { + + private final KeywordService keywordService; + private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final KeywordRankRepository keywordRankRepository; + private final ChatMemoryRepository chatMemoryRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; + + @Value("${custom.ai.title-extraction}") + private String titleExtraction; + @Value("{$custom.ai.keyword-extraction}") + private String keywordExtraction; + + @Async + @Transactional + public void processHandlerTasks(Long historyId, String userMessage, String fullResponse, List similarCaseDocuments, List similarLawDocuments) { + try { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다. historyId: " + historyId)); + + // 1. 메시지 기억 저장 + ChatMemory chatMemory = MessageWindowChatMemory.builder() + .maxMessages(10) + .chatMemoryRepository(chatMemoryRepository) + .build(); + + chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); + chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); + + // 2. 채팅방 제목 설정 / 및 필터 + setHistoryTitle(userMessage, history, fullResponse); + + // 3. 채팅 기록 저장 + saveChatWithDocuments(history, MessageType.USER, userMessage, similarCaseDocuments, similarLawDocuments); + saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); + + // 4. 키워드 추출 및 랭킹 업데이트 + if (!fullResponse.contains("해당 질문은 법률")) { + extractAndUpdateKeywordRanks(userMessage); + } + } catch (Exception e) { + log.error("에러 발생: {}", historyId, e); + } + } + + private void setHistoryTitle(String userMessage, History history, String fullResponse) { + String targetText = fullResponse.contains("해당 질문은 법률") ? userMessage : fullResponse; + TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); + history.setTitle(titleDto.getTitle()); + historyRepository.save(history); // @Transactional 어노테이션으로 인해 메소드 종료 시 자동 저장되지만, 명시적으로 호출할 수도 있습니다. + } + + private void extractAndUpdateKeywordRanks(String message) { + KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); + if (keywordResponse == null || keywordResponse.getKeyword() == null) { + return; + } + + KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); + + if (keywordRank == null) { + keywordRank = KeywordRank.builder() + .keyword(keywordResponse.getKeyword()) + .score(1L) + .build(); + } else { + keywordRank.setScore(keywordRank.getScore() + 1); + } + keywordRankRepository.save(keywordRank); + } + + private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + Chat chat = chatRepository.save(Chat.builder() + .historyId(history) + .type(type) + .message(message) + .build()); + + // Ai 메시지가 저장될 때 관련 문서 저장 + if (type == MessageType.ASSISTANT) { + if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { + List chatPrecedents = similarCaseDocuments.stream() + .map(doc -> ChatPrecedent.builder() + .chatId(chat) + .precedentContent(doc.getText()) + .caseNumber(doc.getMetadata().get("caseNumber").toString()) + .caseName(doc.getMetadata().get("caseName").toString()) + .build()) + .collect(Collectors.toList()); + chatPrecedentRepository.saveAll(chatPrecedents); + } + + if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { + List chatLaws = similarLawDocuments.stream() + .map(doc -> ChatLaw.builder() + .chatId(chat) + .content(doc.getText()) + .lawName(doc.getMetadata().get("lawName").toString()) + .build()) + .collect(Collectors.toList()); + chatLawRepository.saveAll(chatLaws); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index a14af9e..928e8d6 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -4,10 +4,8 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatPrecedentDto; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatRequest; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; -import com.ai.lawyer.domain.chatbot.entity.*; -import com.ai.lawyer.domain.chatbot.repository.*; +import com.ai.lawyer.domain.chatbot.entity.History; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.qdrant.service.QdrantService; @@ -17,12 +15,15 @@ import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import java.util.HashMap; @@ -36,66 +37,55 @@ public class ChatBotService { private final ChatClient chatClient; - private final QdrantService qdrantService; private final HistoryService historyService; - private final KeywordService keywordService; - private final ChatRepository chatRepository; + private final AsyncPostChatProcessingService asyncPostChatProcessingService; + + private final MemberRepository memberRepository; private final HistoryRepository historyRepository; - private final KeywordRankRepository keywordRankRepository; private final ChatMemoryRepository chatMemoryRepository; - private final MemberRepository memberRepository; - private final ChatPrecedentRepository chatPrecedentRepository; - private final ChatLawRepository chatLawRepository; @Value("${custom.ai.system-message}") private String systemMessageTemplate; - @Value("${custom.ai.title-extraction}") - private String titleExtraction; - @Value("{$custom.ai.keyword-extraction}") - private String keywordExtraction; // 핵심 로직 - // 멤버 조회 -> 벡터 검색 (판례, 법령) -> 프롬프트 생성 (시스템, 유저) -> 채팅 클라이언트 호출 (스트림) -> 응답 저장, 제목/키워드 추출 - public Flux sendMessage(Long memberId, ChatRequest chatChatRequestDto, Long roomId) { + // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> (비동기 후처리) -> 응답 반환 + @Transactional + public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); // 벡터 검색 (판례, 법령) - List similarCaseDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "판례"); - List similarLawDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "법령"); + List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); + List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); - // 판례와 법령 정보를 구분 있게 포맷팅 String caseContext = formatting(similarCaseDocuments); String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 or 생성 -> 없으면 생성 + // 채팅방 조회 또는 생성 History history = getOrCreateRoom(member, roomId); - // 메시지 기억 관리 (최대 10개) - // 멀티턴 -> 10개까지 기억 이거 안하면 매번 처음부터 대화 (멍충한 AI) - ChatMemory chatMemory = saveChatMemory(chatChatRequestDto, history); + // 메시지 기억 관리 (User 메시지 추가) + ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); // 프롬프트 생성 Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // 복잡하긴 한데 이게 제일 깔끔한듯 + // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 return chatClient.prompt(prompt) .stream() .content() .collectList() .map(fullResponseList -> String.join("", fullResponseList)) - .doOnNext(fullResponse -> handlerTasks(chatChatRequestDto, history, fullResponse, chatMemory, similarCaseDocuments, similarLawDocuments)) // 응답이 완성되면 후처리 실행 (대화 저장, 키워드/제목 추출 등) - .map(fullResponse -> ChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments) // 최종적으로 ChatResponse DTO 생성 - ).flux() - .onErrorResume(throwable -> Flux.just(handleError(history))); // 에러 발생 시 에러 핸들링 -> 재전송 유도 + .doOnNext(fullResponse -> asyncPostChatProcessingService.processHandlerTasks(history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, similarCaseDocuments, similarLawDocuments)) // 비동기 후처리 + .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) + .flux() + .onErrorResume(throwable -> Flux.just(handleError(history))); } - private ChatResponse ChatResponse(History history, String fullResponse, List cases, List laws) { - + private ChatResponse createChatResponse(History history, String fullResponse, List cases, List laws) { ChatPrecedentDto precedentDto = null; if (cases != null && !cases.isEmpty()) { Document firstCase = cases.get(0); @@ -110,118 +100,32 @@ private ChatResponse ChatResponse(History history, String fullResponse, List ai 답변은 비동기 후처리에서 추가 + chatMemory.add(String.valueOf(history.getHistoryId()), new UserMessage(chatRequestDto.getMessage())); return chatMemory; } private Prompt getPrompt(String caseContext, String lawContext, ChatMemory chatMemory, History history) { - Map promptContext = new HashMap<>(); promptContext.put("caseContext", caseContext); promptContext.put("lawContext", lawContext); - // 시스템 메시지와 사용자 메시지 생성 가공 PromptTemplate promptTemplate = new PromptTemplate(systemMessageTemplate); Message systemMessage = new SystemMessage(promptTemplate.create(promptContext).getContents()); UserMessage userMessage = new UserMessage(chatMemory.get(history.getHistoryId().toString()).toString()); - Prompt prompt = new Prompt(List.of(systemMessage, userMessage)); - - return prompt; - } - - private ChatResponse handleError(History history) { - return ChatResponse.builder() - .roomId(history.getHistoryId()) - .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") - .build(); - } - - private void handlerTasks(ChatRequest chatDto, History history, String fullResponse, ChatMemory chatMemory, List similarCaseDocuments, List similarLawDocuments) { - - // 메시지 기억 저장 - chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); - chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); - - // 채팅방 제목 설정 / 및 필터 (법과 관련 없는 질문) - setHistoryTitle(chatDto, history, fullResponse); - - // 채팅 기록 저장 - saveChatWithDocuments(history, MessageType.USER, chatDto.getMessage(), similarCaseDocuments, similarLawDocuments); - saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); - - // 키워드 추출 및 키워드 랭킹 저장 (법과 관련 없는 질문은 제외) - if (!fullResponse.contains("해당 질문은 법률")) { - extractAndUpdateKeywordRanks(chatDto.getMessage()); - } - - } - - private void extractAndUpdateKeywordRanks(String message) { - KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); - - KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); - - if (keywordRank == null) { - keywordRank = KeywordRank.builder() - .keyword(keywordResponse.getKeyword()) - .score(1L) - .build(); - } else { - keywordRank.setScore(keywordRank.getScore() + 1); - } - - keywordRankRepository.save(keywordRank); - - } - - private void setHistoryTitle(ChatRequest chatDto, History history, String fullResponse) { - String targetText = fullResponse.contains("해당 질문은 법률") ? chatDto.getMessage() : fullResponse; - TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); - history.setTitle(titleDto.getTitle()); - historyRepository.save(history); - } - - private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { - Chat chat = chatRepository.save(Chat.builder() - .historyId(history) - .type(type) - .message(message) - .build()); - - if (type == MessageType.USER && similarCaseDocuments != null) { - List chatPrecedents = similarCaseDocuments.stream() - .map(doc -> ChatPrecedent.builder() - .chatId(chat) - .precedentContent(doc.getText()) - .caseNumber(doc.getMetadata().get("caseNumber").toString()) - .caseName(doc.getMetadata().get("caseName").toString()) - .build()) - .toList(); - chatPrecedentRepository.saveAll(chatPrecedents); - - List chatLaws = similarLawDocuments.stream() - .map(doc -> ChatLaw.builder() - .chatId(chat) - .content(doc.getText()) - .lawName(doc.getMetadata().get("lawName").toString()) - .build()) - .toList(); - - chatLawRepository.saveAll(chatLaws); - } + return new Prompt(List.of(systemMessage, userMessage)); } private History getOrCreateRoom(Member member, Long roomId) { @@ -232,11 +136,19 @@ private History getOrCreateRoom(Member member, Long roomId) { } } - private String formatting(List similarCaseDocuments) { - String context = similarCaseDocuments.stream() + private String formatting(List documents) { + if (documents == null || documents.isEmpty()) { + return ""; + } + return documents.stream() .map(Document::getFormattedContent) .collect(Collectors.joining("\n\n---\n\n")); - return context; } + private ChatResponse handleError(History history) { + return ChatResponse.builder() + .roomId(history.getHistoryId()) + .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") + .build(); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java deleted file mode 100644 index 2231240..0000000 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ai.lawyer.domain.chatbot.service; - -import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; -import com.ai.lawyer.domain.chatbot.entity.Chat; -import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ChatService { - - private final HistoryRepository historyRepository; - private final MemberRepository memberRepository; - - public ResponseEntity> getChatHistory(Long memberId, Long roomId) { - - Member member = memberRepository.findById(memberId).orElseThrow( - () -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); - - List chats = historyRepository.findByHistoryIdAndMemberId(roomId, member).getChats(); - List chatDtos = new ArrayList<>(); - - for (Chat chat : chats) { - ChatHistoryDto dto = ChatHistoryDto.from(chat); - chatDtos.add(dto); - } - - return ResponseEntity.ok(chatDtos); - - } - -} diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java index fb1da13..3ed27b6 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java @@ -1,12 +1,15 @@ package com.ai.lawyer.domain.chatbot.service; +import com.ai.lawyer.domain.chatbot.dto.ChatDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; +import com.ai.lawyer.domain.chatbot.entity.Chat; import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.exception.HistoryNotFoundException; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -49,6 +52,24 @@ public String deleteHistory(Long memberId, Long roomId) { } + public ResponseEntity> getChatHistory(Long memberId, Long roomId) { + + Member member = memberRepository.findById(memberId).orElseThrow( + () -> new IllegalArgumentException("존재하지 않는 회원입니다.") + ); + + List chats = historyRepository.findByHistoryIdAndMemberId(roomId, member).getChats(); + List chatDtos = new ArrayList<>(); + + for (Chat chat : chats) { + ChatDto.ChatHistoryDto dto = ChatDto.ChatHistoryDto.from(chat); + chatDtos.add(dto); + } + + return ResponseEntity.ok(chatDtos); + + } + public History getHistory(Long roomId) { return historyRepository.findById(roomId).orElseThrow( () -> new HistoryNotFoundException(roomId) From 483bdb65860d1ff03e890624d2b3e60435121b8d Mon Sep 17 00:00:00 2001 From: asowjdan Date: Sat, 11 Oct 2025 06:28:55 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix[member]:=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EB=82=B4=EC=9A=A9=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=ED=95=AD=EB=AA=A9=EC=97=90=20cascade=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/domain/chatbot/entity/Chat.java | 4 +- .../chatbot/repository/ChatLawRepository.java | 10 +++ .../repository/ChatPrecedentRepository.java | 10 +++ .../chatbot/repository/ChatRepository.java | 10 +++ .../domain/member/service/MemberService.java | 63 ++++++++++--------- 5 files changed, 67 insertions(+), 30 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java index 5067209..80765d9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java @@ -33,10 +33,10 @@ public class Chat { @Lob private String message; - @OneToMany(mappedBy = "chatId") + @OneToMany(mappedBy = "chatId", cascade = CascadeType.ALL, orphanRemoval = true) private List chatPrecedents; - @OneToMany(mappedBy = "chatId") + @OneToMany(mappedBy = "chatId", cascade = CascadeType.ALL, orphanRemoval = true) private List chatLaws; @CreationTimestamp diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java index be1674a..bea2a9a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java @@ -2,8 +2,18 @@ import com.ai.lawyer.domain.chatbot.entity.ChatLaw; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ChatLawRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 ChatLaw 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java index a0e3766..820456d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java @@ -2,6 +2,16 @@ import com.ai.lawyer.domain.chatbot.entity.ChatPrecedent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ChatPrecedentRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 ChatPrecedent 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM ChatPrecedent cp WHERE cp.chatId.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java index 99d3152..08ca84d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java @@ -2,8 +2,18 @@ import com.ai.lawyer.domain.chatbot.entity.Chat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ChatRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 Chat 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM Chat c WHERE c.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index e2f6f62..2b2fc88 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -8,6 +8,9 @@ import com.ai.lawyer.domain.post.repository.PostRepository; import com.ai.lawyer.domain.poll.repository.PollVoteRepository; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatLawRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.email.service.EmailService; @@ -33,6 +36,9 @@ public class MemberService { private final PostRepository postRepository; private final PollVoteRepository pollVoteRepository; private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; public MemberService( MemberRepository memberRepository, @@ -43,7 +49,10 @@ public MemberService( EmailAuthService emailAuthService, PostRepository postRepository, PollVoteRepository pollVoteRepository, - HistoryRepository historyRepository) { + HistoryRepository historyRepository, + ChatRepository chatRepository, + ChatPrecedentRepository chatPrecedentRepository, + ChatLawRepository chatLawRepository) { this.memberRepository = memberRepository; this.passwordEncoder = passwordEncoder; this.tokenProvider = tokenProvider; @@ -53,6 +62,9 @@ public MemberService( this.postRepository = postRepository; this.pollVoteRepository = pollVoteRepository; this.historyRepository = historyRepository; + this.chatRepository = chatRepository; + this.chatPrecedentRepository = chatPrecedentRepository; + this.chatLawRepository = chatLawRepository; } @org.springframework.beans.factory.annotation.Autowired(required = false) @@ -225,37 +237,32 @@ public void deleteMember(String loginId) { // 2. 연관된 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) log.info("연관 데이터 삭제 시작: memberId={}", memberId); - // 2-1. 채팅 히스토리 삭제 (Chat 엔티티도 cascade로 함께 삭제됨) - try { - historyRepository.deleteByMemberIdValue(memberId); - log.info("채팅 히스토리 삭제 완료: memberId={}", memberId); - } catch (Exception e) { - log.error("채팅 히스토리 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); - } + // 2-1. ChatPrecedent, ChatLaw 삭제 (Chat의 FK 참조) + chatPrecedentRepository.deleteByMemberIdValue(memberId); + log.info("채팅 판례 삭제 완료: memberId={}", memberId); - // 2-2. 투표 내역 삭제 - try { - pollVoteRepository.deleteByMemberIdValue(memberId); - log.info("투표 내역 삭제 완료: memberId={}", memberId); - } catch (Exception e) { - log.error("투표 내역 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); - } + chatLawRepository.deleteByMemberIdValue(memberId); + log.info("채팅 법령 삭제 완료: memberId={}", memberId); - // 2-3. 게시글 삭제 (Poll 엔티티도 cascade로 함께 삭제됨) - try { - postRepository.deleteByMemberIdValue(memberId); - log.info("게시글 삭제 완료: memberId={}", memberId); - } catch (Exception e) { - log.error("게시글 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); - } + // 2-2. Chat 삭제 (History의 FK 참조) + chatRepository.deleteByMemberIdValue(memberId); + log.info("채팅 삭제 완료: memberId={}", memberId); + + // 2-3. History 삭제 (Member의 FK 참조) + historyRepository.deleteByMemberIdValue(memberId); + log.info("채팅 히스토리 삭제 완료: memberId={}", memberId); + + // 2-4. 투표 내역 삭제 + pollVoteRepository.deleteByMemberIdValue(memberId); + log.info("투표 내역 삭제 완료: memberId={}", memberId); + + // 2-5. 게시글 삭제 (Poll 엔티티도 cascade로 함께 삭제됨) + postRepository.deleteByMemberIdValue(memberId); + log.info("게시글 삭제 완료: memberId={}", memberId); // 3. Redis 토큰 삭제 - try { - tokenProvider.deleteAllTokens(loginId); - log.info("Redis 토큰 삭제 완료: loginId={}", loginId); - } catch (Exception e) { - log.error("Redis 토큰 삭제 실패: loginId={}, error={}", loginId, e.getMessage()); - } + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); // 4. 회원 정보 삭제 final Long finalMemberId = memberId; From c4dc46e24bd449cc03dc222b466200d213544aa3 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Sat, 11 Oct 2025 06:29:22 +0900 Subject: [PATCH 9/9] =?UTF-8?q?test[member]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberServiceOAuth2Test.java | 14 +++++++++++++- .../member/service/MemberServiceTest.java | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java index 36ba2ae..549e902 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java @@ -66,6 +66,15 @@ class MemberServiceOAuth2Test { @Mock private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatRepository chatRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository chatPrecedentRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatLawRepository chatLawRepository; + @Mock private HttpServletResponse response; @@ -86,7 +95,10 @@ void setUp() { emailAuthService, postRepository, pollVoteRepository, - historyRepository + historyRepository, + chatRepository, + chatPrecedentRepository, + chatLawRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 23e6116..0b0659c 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -61,6 +61,15 @@ class MemberServiceTest { @Mock private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatRepository chatRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository chatPrecedentRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatLawRepository chatLawRepository; + @Mock private HttpServletResponse response; @@ -85,7 +94,10 @@ void setUp() { emailAuthService, postRepository, pollVoteRepository, - historyRepository + historyRepository, + chatRepository, + chatPrecedentRepository, + chatLawRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); @@ -315,7 +327,10 @@ void withdraw_Success() { // 1. 회원 조회 verify(memberRepository).findByLoginId(loginId); - // 2. 연관 데이터 명시적 삭제 (순서 중요) + // 2. 연관 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) + verify(chatPrecedentRepository).deleteByMemberIdValue(member.getMemberId()); + verify(chatLawRepository).deleteByMemberIdValue(member.getMemberId()); + verify(chatRepository).deleteByMemberIdValue(member.getMemberId()); verify(historyRepository).deleteByMemberIdValue(member.getMemberId()); verify(pollVoteRepository).deleteByMemberIdValue(member.getMemberId()); verify(postRepository).deleteByMemberIdValue(member.getMemberId());