diff --git a/backend/src/main/java/com/ai/lawyer/BackendApplication.java b/backend/src/main/java/com/ai/lawyer/BackendApplication.java index f35a28fd..89bfdf4a 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 a18190ed..557f528f 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 93095e2c..16c21043 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/entity/Chat.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java index 5067209f..80765d9b 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/entity/History.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java index 16eb6cc8..fb0f1a2e 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/ChatLawRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java index be1674a5..bea2a9a9 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 a0e37661..820456d1 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 99d31525..08ca84db 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/chatbot/repository/HistoryRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java index 30828254..ec094d4b 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/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java new file mode 100644 index 00000000..419371a0 --- /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 a14af9eb..928e8d67 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 22312409..00000000 --- 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 fb1da134..3ed27b6f 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) 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 0806e9b5..2b2fc888 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,12 @@ 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.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; @@ -27,6 +33,12 @@ 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; + private final ChatRepository chatRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; public MemberService( MemberRepository memberRepository, @@ -34,13 +46,25 @@ public MemberService( TokenProvider tokenProvider, CookieUtil cookieUtil, EmailService emailService, - EmailAuthService emailAuthService) { + EmailAuthService emailAuthService, + PostRepository postRepository, + PollVoteRepository pollVoteRepository, + HistoryRepository historyRepository, + ChatRepository chatRepository, + ChatPrecedentRepository chatPrecedentRepository, + ChatLawRepository chatLawRepository) { 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; + this.chatRepository = chatRepository; + this.chatPrecedentRepository = chatPrecedentRepository; + this.chatLawRepository = chatLawRepository; } @org.springframework.beans.factory.annotation.Autowired(required = false) @@ -186,24 +210,76 @@ 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. ChatPrecedent, ChatLaw 삭제 (Chat의 FK 참조) + chatPrecedentRepository.deleteByMemberIdValue(memberId); + log.info("채팅 판례 삭제 완료: memberId={}", memberId); + + chatLawRepository.deleteByMemberIdValue(memberId); + log.info("채팅 법령 삭제 완료: memberId={}", memberId); + + // 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 토큰 삭제 + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); + + // 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/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index 66e9ec99..6c34b1b4 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 4ac94772..f86b32bf 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,11 +2,24 @@ 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; 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); + + /** + * 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/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 97d79daa..ebe9f030 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 32ffb013..feeb9323 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 e8bf31dd..e13ec6c1 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/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index a69ff05f..ba532bf9 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/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index a2ed98b6..2988846f 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 cd275426..7d556fc5 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()) @@ -268,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 @@ -326,4 +330,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 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 b41e07b9..aa8a7a66 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/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java index a432edb9..549e9027 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,24 @@ 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 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; @@ -74,7 +92,13 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + 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 1c06e932..0b0659c2 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,24 @@ 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 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; @@ -73,7 +91,13 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository, + chatRepository, + chatPrecedentRepository, + chatLawRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); @@ -300,7 +324,21 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then + // 1. 회원 조회 verify(memberRepository).findByLoginId(loginId); + + // 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()); + + // 3. Redis 토큰 삭제 + verify(tokenProvider).deleteAllTokens(loginId); + + // 4. 회원 삭제 verify(memberRepository).delete(member); } 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 7425db95..ce8b0374 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 1f986f6f..888b2b10 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 22d0fbcb..05ef3d0e 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