Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.ai.lawyer.domain.chatbot.entity;

import com.ai.lawyer.domain.member.entity.Member;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -24,9 +23,10 @@ public class History {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long historyId;

@ManyToOne
@JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "FK_HISTORY_MEMBER"))
private Member memberId;
// Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 (ConstraintMode.NO_CONSTRAINT)
// member_id를 직접 저장하고, 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장
@Column(name = "member_id")
private Long memberId;

@OneToMany(mappedBy = "historyId", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Chat> chats;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public interface ChatLawRepository extends JpaRepository<ChatLaw, Long> {

/**
* member_id에 해당하는 모든 ChatLaw 삭제 (회원 탈퇴 시 사용)
* History.memberId가 Long 타입이므로 직접 비교
*/
@Modifying
@Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId.memberId = :memberId")
@Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId = :memberId")
void deleteByMemberIdValue(@Param("memberId") Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public interface ChatPrecedentRepository extends JpaRepository<ChatPrecedent, Lo

/**
* member_id에 해당하는 모든 ChatPrecedent 삭제 (회원 탈퇴 시 사용)
* History.memberId가 Long 타입이므로 직접 비교
*/
@Modifying
@Query("DELETE FROM ChatPrecedent cp WHERE cp.chatId.historyId.memberId.memberId = :memberId")
@Query("DELETE FROM ChatPrecedent cp WHERE cp.chatId.historyId.memberId = :memberId")
void deleteByMemberIdValue(@Param("memberId") Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public interface ChatRepository extends JpaRepository<Chat, Long> {

/**
* member_id에 해당하는 모든 Chat 삭제 (회원 탈퇴 시 사용)
* History.memberId가 Long 타입이므로 직접 비교
*/
@Modifying
@Query("DELETE FROM Chat c WHERE c.historyId.memberId.memberId = :memberId")
@Query("DELETE FROM Chat c WHERE c.historyId.memberId = :memberId")
void deleteByMemberIdValue(@Param("memberId") Long memberId);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.ai.lawyer.domain.chatbot.repository;

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;
Expand All @@ -13,16 +12,17 @@
@Repository
public interface HistoryRepository extends JpaRepository<History, Long> {

List<History> findAllByMemberId(Member memberId);
// member_id로 직접 조회 (Member, OAuth2Member 모두 지원)
List<History> findAllByMemberId(Long memberId);

History findByHistoryIdAndMemberId(Long roomId, Member memberId);
History findByHistoryIdAndMemberId(Long roomId, Long memberId);

/**
* member_id로 채팅 히스토리 삭제 (회원 탈퇴 시 사용)
* Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제
*/
@Modifying
@Query("DELETE FROM History h WHERE h.memberId.memberId = :memberId")
@Query("DELETE FROM History h WHERE h.memberId = :memberId")
void deleteByMemberIdValue(@Param("memberId") Long memberId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -60,68 +62,78 @@ public class ChatBotService {
@Transactional
public Flux<ChatResponse> sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) {

Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
// Mono.fromCallable()과 subscribeOn()을 사용하여 블로킹 작업을 별도 스레드에서 실행
// boundedElastic 스케줄러는 블로킹 I/O 작업에 최적화된 스레드 풀 사용
return Mono.fromCallable(() -> {
// 멤버 조회 (블로킹)
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));

// 벡터 검색 (판례, 법령)
List<Document> similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례");
List<Document> similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령");
// 벡터 검색 (판례, 법령) (블로킹)
List<Document> similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례");
List<Document> similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령");

String caseContext = formatting(similarCaseDocuments);
String lawContext = formatting(similarLawDocuments);
String caseContext = formatting(similarCaseDocuments);
String lawContext = formatting(similarLawDocuments);

// 채팅방 조회 또는 생성
History history = getOrCreateRoom(member, roomId);
// 채팅방 조회 또는 생성 (블로킹)
History history = getOrCreateRoom(member, roomId);

// 메시지 기억 관리 (User 메시지 추가)
ChatMemory chatMemory = saveChatMemory(chatRequestDto, history);
// 메시지 기억 관리 (User 메시지 추가)
ChatMemory chatMemory = saveChatMemory(chatRequestDto, history);

// 프롬프트 생성
Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history);
// 프롬프트 생성
Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history);

// LLM 스트리밍 호출 및 클라이언트에게 즉시 응답
return chatClient.prompt(prompt)
// 준비된 데이터를 담은 컨텍스트 객체 반환
return new PreparedChatContext(prompt, history, similarCaseDocuments, similarLawDocuments);
})
.subscribeOn(Schedulers.boundedElastic()) // 블로킹 작업을 별도 스레드에서 실행
.flatMapMany(context -> {
// LLM 스트리밍 호출 및 클라이언트에게 즉시 응답
return chatClient.prompt(context.prompt)
.stream()
.content()
.collectList()
.map(fullResponseList -> String.join("", fullResponseList))
.doOnNext(fullResponse -> {

// Document를 DTO로 변환
List<DocumentDto> caseDtos = similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList());
List<DocumentDto> lawDtos = similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList());
List<DocumentDto> caseDtos = context.similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList());
List<DocumentDto> lawDtos = context.similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList());

// Kafka로 보낼 이벤트 객체
ChatPostProcessEvent event = new ChatPostProcessEvent(
history.getHistoryId(),
context.history.getHistoryId(),
chatRequestDto.getMessage(),
fullResponse,
caseDtos,
lawDtos
);

// Kafka 이벤트 발행
kafkaTemplate.send(POST_PROCESSING_TOPIC, event);

})
.map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments))
.map(fullResponse -> createChatResponse(context.history, fullResponse, context.similarCaseDocuments, context.similarLawDocuments))
.flux()
.onErrorResume(throwable -> {
log.error("스트리밍 처리 중 에러 발생 (historyId: {})", history.getHistoryId(), throwable);
return Flux.just(handleError(history));
log.error("스트리밍 처리 중 에러 발생 (historyId: {})", context.history.getHistoryId(), throwable);
return Flux.just(handleError(context.history));
});
});
}

private ChatResponse createChatResponse(History history, String fullResponse, List<Document> cases, List<Document> laws) {
ChatPrecedentDto precedentDto = null;
if (cases != null && !cases.isEmpty()) {
Document firstCase = cases.get(0);
Document firstCase = cases.getFirst();
precedentDto = ChatPrecedentDto.from(firstCase);
}

ChatLawDto lawDto = null;
if (laws != null && !laws.isEmpty()) {
Document firstLaw = laws.get(0);
Document firstLaw = laws.getFirst();
lawDto = ChatLawDto.from(firstLaw);
}

Expand Down Expand Up @@ -159,7 +171,7 @@ private History getOrCreateRoom(Member member, Long roomId) {
if (roomId != null) {
return historyService.getHistory(roomId);
} else {
return historyRepository.save(History.builder().memberId(member).build());
return historyRepository.save(History.builder().memberId(member.getMemberId()).build());
}
}

Expand All @@ -178,4 +190,24 @@ private ChatResponse handleError(History history) {
.message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.")
.build();
}

/**
* 블로킹 작업에서 준비된 데이터를 담는 컨텍스트 클래스
* 리액티브 체인에서 데이터를 전달하기 위한 내부 클래스
*/
private static class PreparedChatContext {
final Prompt prompt;
final History history;
final List<Document> similarCaseDocuments;
final List<Document> similarLawDocuments;

PreparedChatContext(Prompt prompt, History history,
List<Document> similarCaseDocuments,
List<Document> similarLawDocuments) {
this.prompt = prompt;
this.history = history;
this.similarCaseDocuments = similarCaseDocuments;
this.similarLawDocuments = similarLawDocuments;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
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 com.ai.lawyer.infrastructure.redis.service.ChatCacheService;
import lombok.RequiredArgsConstructor;
Expand All @@ -28,11 +27,12 @@ public class HistoryService {

public List<HistoryDto> getHistoryTitle(Long memberId) {

Member member = memberRepository.findById(memberId).orElseThrow(
// 회원 존재 여부 확인
memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("존재하지 않는 회원입니다.")
);

List<History> rooms = historyRepository.findAllByMemberId(member);
List<History> rooms = historyRepository.findAllByMemberId(memberId);
List<HistoryDto> roomDtos = new ArrayList<>();

for (History room : rooms)
Expand All @@ -45,11 +45,12 @@ public String deleteHistory(Long memberId, Long roomId) {

getHistory(roomId);

Member member = memberRepository.findById(memberId).orElseThrow(
// 회원 존재 여부 확인
memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("존재하지 않는 회원입니다.")
);

History room = historyRepository.findByHistoryIdAndMemberId(roomId, member);
History room = historyRepository.findByHistoryIdAndMemberId(roomId, memberId);

historyRepository.delete(room);
chatCacheService.clearChatHistory(roomId);
Expand All @@ -61,7 +62,8 @@ public String deleteHistory(Long memberId, Long roomId) {
@Transactional(readOnly = true)
public ResponseEntity<List<ChatHistoryDto>> getChatHistory(Long memberId, Long roomId) {

Member member = memberRepository.findById(memberId).orElseThrow(
// 회원 존재 여부 확인
memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("존재하지 않는 회원입니다.")
);

Expand All @@ -72,7 +74,7 @@ public ResponseEntity<List<ChatHistoryDto>> getChatHistory(Long memberId, Long r
}

// 2. DB에서 조회 후 캐시에 저장
History history = historyRepository.findByHistoryIdAndMemberId(roomId, member);
History history = historyRepository.findByHistoryIdAndMemberId(roomId, memberId);
List<Chat> chats = history.getChats();

// 엔티티 -> DTO 변환
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ public class PollVote {
@JoinColumn(name = "poll_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_POLL"))
private Poll poll;

// Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거
// 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장
// foreignKey 제약조건 비활성화 (ConstraintMode.NO_CONSTRAINT)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_MEMBER"))
@JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
import com.ai.lawyer.domain.poll.entity.*;
import com.ai.lawyer.domain.poll.repository.*;
import com.ai.lawyer.domain.member.entity.Member;
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;
Expand Down Expand Up @@ -242,7 +240,7 @@ public void closePoll(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)) {
if (poll.getPost() == null || !poll.getPost().getMemberId().equals(memberId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 삭제할 수 있습니다.");
}
// 1. 이 Poll을 참조하는 Post가 있으면 연결 해제
Expand Down Expand Up @@ -312,7 +310,7 @@ public Long getVoteCountByPostId(Long postId) {
public PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId) {
Poll poll = pollRepository.findById(pollId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "수정할 투표를 찾을 수 없습니다."));
if (!poll.getPost().getMember().getMemberId().equals(memberId)) {
if (!poll.getPost().getMemberId().equals(memberId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 수정할 수 있습니다.");
}
if (getVoteCountByPollId(pollId) > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.ai.lawyer.domain.post.entity;

import com.ai.lawyer.domain.member.entity.Member;
import com.ai.lawyer.domain.poll.entity.Poll;
import jakarta.persistence.*;
import lombok.*;
Expand All @@ -21,9 +20,10 @@ public class Post {
@Column(name = "post_id")
private Long postId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = true, foreignKey = @ForeignKey(name = "FK_POST_MEMBER"))
private Member member;
// Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 (ConstraintMode.NO_CONSTRAINT)
// member_id를 직접 저장하고, 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장
@Column(name = "member_id")
private Long memberId;

@Column(name = "post_name", length = 100, nullable = false)
private String postName;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.ai.lawyer.domain.post.repository;

import com.ai.lawyer.domain.member.entity.Member;
import com.ai.lawyer.domain.post.entity.Post;
import com.ai.lawyer.domain.poll.entity.Poll.PollStatus;
import org.springframework.data.domain.Page;
Expand All @@ -15,17 +14,18 @@

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByMember(Member member);
// member_id로 직접 조회 (Member, OAuth2Member 모두 지원)
List<Post> findByMemberId(Long memberId);

/**
* member_id로 게시글 삭제 (회원 탈퇴 시 사용)
* Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제
*/
@Modifying
@Query("DELETE FROM Post p WHERE p.member.memberId = :memberId")
@Query("DELETE FROM Post p WHERE p.memberId = :memberId")
void deleteByMemberIdValue(@Param("memberId") Long memberId);

Page<Post> findByMember(Member member, Pageable pageable);
Page<Post> findByMemberId(Long memberId, Pageable pageable);
Page<Post> findByPoll_Status(PollStatus status, Pageable pageable);
Page<Post> findByPoll_StatusAndPoll_PollIdIn(PollStatus status, List<Long> pollIds, Pageable pageable);
Page<Post> findByPoll_PollIdIn(List<Long> pollIds, Pageable pageable);
Expand Down
Loading