diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index 3c898096..49998111 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -463,7 +463,7 @@ jobs: # prod.env 복원 install -d -m 700 /home/ec2-user/configs - printf "%s" "${{ secrets.PROD_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env + printf "%s" "${{ secrets.PROD_TEST_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env chmod 600 /home/ec2-user/configs/prod.env test -s /home/ec2-user/configs/prod.env || { echo "prod.env empty"; exit 1; } diff --git a/backend/build.gradle b/backend/build.gradle index 008907e0..615add1b 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -41,7 +41,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5' implementation 'org.springframework.boot:spring-boot-starter-batch' - implementation 'org.springframework.kafka:spring-kafka' testImplementation 'org.springframework.kafka:spring-kafka-test' // API Documentation (문서화) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 73a04009..d0204b3f 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -82,40 +82,8 @@ services: timeout: 5s retries: 10 - zookeeper: - image: confluentinc/cp-zookeeper:7.4.4 - container_name: zookeeper - restart: unless-stopped - ports: - - "2181:2181" - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - - kafka: - image: confluentinc/cp-kafka:7.4.4 - container_name: kafka - restart: unless-stopped - depends_on: - - zookeeper - ports: - - "9092:9092" - environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092 - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092 - KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 - volumes: - - kafka-data:/var/lib/kafka/data - volumes: mysql-data: redis-data: qdrant-data: - ollama-data: - kafka-data: \ No newline at end of file + ollama-data: \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index 0a4a6d6a..554d36bf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -3,22 +3,17 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatRequest; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; import com.ai.lawyer.domain.chatbot.service.ChatBotService; +import com.ai.lawyer.global.util.AuthUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; @Slf4j @Tag(name = "ChatBot API", description = "챗봇 관련 API") -@Controller +@RestController @RequiredArgsConstructor @RequestMapping("/api/chat") public class ChatBotController { @@ -26,17 +21,27 @@ public class ChatBotController { private final ChatBotService chatBotService; @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") - @PostMapping("/message") - public ResponseEntity> postNewMessage( - @AuthenticationPrincipal Long memberId, - @RequestBody ChatRequest chatRequest) { - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, null)); + @PostMapping(value = "/message") + public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { + + Long memberId = AuthUtil.getAuthenticatedMemberId(); + + log.info("새로운 채팅 요청: memberId={}", memberId); + + return chatBotService.sendMessage(memberId, chatRequest, null); } @Operation(summary = "02. 기존 채팅", description = "기존 채팅방에 메시지를 보내고 챗봇과 대화를 이어감") - @PostMapping("{roomId}/message") - public ResponseEntity> postMessage(@AuthenticationPrincipal Long memberId, @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, roomId)); + @PostMapping(value = "{roomId}/message") + public Flux postMessage( + @RequestBody ChatRequest chatRequest, + @PathVariable(value = "roomId", required = false) Long roomId) { + + Long memberId = AuthUtil.getAuthenticatedMemberId(); + + log.info("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); + + return chatBotService.sendMessage(memberId, chatRequest, roomId); } } \ No newline at end of file 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 fb0f1a2e..b646cdfe 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 @@ -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; @@ -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 chats; 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 bea2a9a9..029c9c96 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 @@ -12,8 +12,9 @@ public interface ChatLawRepository extends JpaRepository { /** * 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); } 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 820456d1..dc803927 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 @@ -10,8 +10,9 @@ public interface ChatPrecedentRepository extends JpaRepository { /** * 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); } 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 ec094d4b..2b4f402c 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 @@ -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; @@ -13,16 +12,17 @@ @Repository public interface HistoryRepository extends JpaRepository { - List findAllByMemberId(Member memberId); + // member_id로 직접 조회 (Member, OAuth2Member 모두 지원) + List 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); } \ 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 index df6efeff..e4780264 100644 --- 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 @@ -1,9 +1,13 @@ package com.ai.lawyer.domain.chatbot.service; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatLawDto; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatPrecedentDto; 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.infrastructure.redis.service.ChatCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.memory.ChatMemory; @@ -13,9 +17,11 @@ 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.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -27,6 +33,8 @@ public class AsyncPostChatProcessingService { private final KeywordService keywordService; + private final ChatCacheService chatCacheService; + private final HistoryRepository historyRepository; private final ChatRepository chatRepository; private final KeywordRankRepository keywordRankRepository; @@ -39,7 +47,7 @@ public class AsyncPostChatProcessingService { @Value("{$custom.ai.keyword-extraction}") private String keywordExtraction; - //@Async + @Async @Transactional public void processHandlerTasks(Long historyId, String userMessage, String fullResponse, List similarCaseDocuments, List similarLawDocuments) { try { @@ -98,6 +106,9 @@ private void extractAndUpdateKeywordRanks(String message) { } private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + List chatPrecedents = new ArrayList<>(); + List chatLaws = new ArrayList<>(); + Chat chat = chatRepository.save(Chat.builder() .historyId(history) .type(type) @@ -107,7 +118,7 @@ private void saveChatWithDocuments(History history, MessageType type, String mes // Ai 메시지가 저장될 때 관련 문서 저장 if (type == MessageType.ASSISTANT) { if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { - List chatPrecedents = similarCaseDocuments.stream() + chatPrecedents = similarCaseDocuments.stream() .map(doc -> ChatPrecedent.builder() .chatId(chat) .precedentContent(doc.getText()) @@ -119,7 +130,7 @@ private void saveChatWithDocuments(History history, MessageType type, String mes } if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { - List chatLaws = similarLawDocuments.stream() + chatLaws = similarLawDocuments.stream() .map(doc -> ChatLaw.builder() .chatId(chat) .content(doc.getText()) @@ -129,5 +140,16 @@ private void saveChatWithDocuments(History history, MessageType type, String mes chatLawRepository.saveAll(chatLaws); } } + + // Redis 캐시에 DTO 저장 + ChatHistoryDto dto = ChatHistoryDto.builder() + .type(type.toString()) + .message(message) + .createdAt(chat.getCreatedAt()) + .precedent(chatPrecedents.isEmpty() ? null : ChatPrecedentDto.from(chatPrecedents.get(0))) + .law(chatLaws.isEmpty() ? null : ChatLawDto.from(chatLaws.get(0))) + .build(); + + chatCacheService.cacheChatMessage(history.getHistoryId(), dto); } } \ 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 f1bb75ec..97481bd1 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 @@ -6,10 +6,6 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; -import com.ai.lawyer.infrastructure.kafka.dto.ChatPostProcessEvent; -import com.ai.lawyer.infrastructure.kafka.dto.DocumentDto; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.qdrant.service.QdrantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,7 +20,6 @@ import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; -import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; @@ -42,27 +37,19 @@ public class ChatBotService { private final ChatClient chatClient; private final QdrantService qdrantService; private final HistoryService historyService; - private final MemberRepository memberRepository; + private final AsyncPostChatProcessingService asyncPostChatProcessingService; + private final HistoryRepository historyRepository; private final ChatMemoryRepository chatMemoryRepository; - // KafkaTemplate 주입 - private final KafkaTemplate kafkaTemplate; - @Value("${custom.ai.system-message}") private String systemMessageTemplate; - // Kafka 토픽 이름 -> 추후 application.yml로 이동 고려 - private static final String POST_PROCESSING_TOPIC = "chat-post-processing"; - // 핵심 로직 // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> Kafka 이벤트 발행 -> 응답 반환 @Transactional public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 벡터 검색 (판례, 법령) List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); @@ -71,57 +58,39 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, String lawContext = formatting(similarLawDocuments); // 채팅방 조회 또는 생성 - History history = getOrCreateRoom(member, roomId); + History history = getOrCreateRoom(memberId, roomId); - // 메시지 기억 관리 (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 -> { - - // Document를 DTO로 변환 - List caseDtos = similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - List lawDtos = similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - - // Kafka로 보낼 이벤트 객체 - ChatPostProcessEvent event = new ChatPostProcessEvent( - history.getHistoryId(), - chatRequestDto.getMessage(), - fullResponse, - caseDtos, - lawDtos - ); - - // Kafka 이벤트 발행 - kafkaTemplate.send(POST_PROCESSING_TOPIC, event); - - }) + .doOnNext(fullResponse -> asyncPostChatProcessingService.processHandlerTasks(history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, similarCaseDocuments, similarLawDocuments)) .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) .flux() .onErrorResume(throwable -> { log.error("스트리밍 처리 중 에러 발생 (historyId: {})", history.getHistoryId(), throwable); return Flux.just(handleError(history)); }); + } private ChatResponse createChatResponse(History history, String fullResponse, List cases, List 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); } @@ -155,11 +124,11 @@ private Prompt getPrompt(String caseContext, String lawContext, ChatMemory chatM return new Prompt(List.of(systemMessage, userMessage)); } - private History getOrCreateRoom(Member member, Long roomId) { + private History getOrCreateRoom(Long memberId, Long roomId) { if (roomId != null) { return historyService.getHistory(roomId); } else { - return historyRepository.save(History.builder().memberId(member).build()); + return historyRepository.save(History.builder().memberId(memberId).build()); } } @@ -178,4 +147,5 @@ private ChatResponse handleError(History history) { .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") .build(); } + } \ No newline at end of file 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 e1d9e1bb..db14eb2b 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 @@ -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; @@ -28,11 +27,12 @@ public class HistoryService { public List getHistoryTitle(Long memberId) { - Member member = memberRepository.findById(memberId).orElseThrow( + // 회원 존재 여부 확인 + memberRepository.findById(memberId).orElseThrow( () -> new IllegalArgumentException("존재하지 않는 회원입니다.") ); - List rooms = historyRepository.findAllByMemberId(member); + List rooms = historyRepository.findAllByMemberId(memberId); List roomDtos = new ArrayList<>(); for (History room : rooms) @@ -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); @@ -61,7 +62,8 @@ public String deleteHistory(Long memberId, Long roomId) { @Transactional(readOnly = true) public ResponseEntity> getChatHistory(Long memberId, Long roomId) { - Member member = memberRepository.findById(memberId).orElseThrow( + // 회원 존재 여부 확인 + memberRepository.findById(memberId).orElseThrow( () -> new IllegalArgumentException("존재하지 않는 회원입니다.") ); @@ -72,7 +74,7 @@ public ResponseEntity> getChatHistory(Long memberId, Long r } // 2. DB에서 조회 후 캐시에 저장 - History history = historyRepository.findByHistoryIdAndMemberId(roomId, member); + History history = historyRepository.findByHistoryIdAndMemberId(roomId, memberId); List chats = history.getChats(); // 엔티티 -> DTO 변환 diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 8c90d7ab..d6bd7266 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -117,7 +117,14 @@ public ResponseEntity oauth2SuccessPage(Authentication authentication, H if (principal instanceof Long) { memberId = (Long) principal; - loginId = (String) authentication.getDetails(); + // Details가 Map이면 loginId 추출 + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); + } else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + } } else if (principal instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); loginId = member.getLoginId(); @@ -203,10 +210,17 @@ public ResponseEntity logout(Authentication authentication, Http if (authentication != null) { // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) - if (authentication.getDetails() instanceof String) { - loginId = (String) authentication.getDetails(); + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); log.info("JWT Details로 로그아웃: loginId={}", loginId); } + // 1-2순위: 이전 버전 호환성 (String으로 저장된 경우) + else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details(legacy)로 로그아웃: loginId={}", loginId); + } // 2순위: PrincipalDetails (OAuth2 직접 로그인) else if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); @@ -257,10 +271,17 @@ public ResponseEntity> withdraw(Authentication authenticatio if (authentication != null) { // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) - if (authentication.getDetails() instanceof String) { - loginId = (String) authentication.getDetails(); + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); log.info("JWT Details로 회원 탈퇴: loginId={}", loginId); } + // 1-2순위: 이전 버전 호환성 (String으로 저장된 경우) + else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details(legacy)로 회원 탈퇴: loginId={}", loginId); + } // 2순위: PrincipalDetails (OAuth2 직접 로그인) else if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java index b90f8525..fd843772 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java @@ -7,6 +7,11 @@ import java.util.List; import java.util.Optional; +/** + * MemberRepository + * findById 호출 시 SmartMemberRepositoryImpl을 통해 AuthUtil로 자동 리다이렉트됩니다. + * 이를 통해 loginType에 따라 Member 또는 OAuth2Member 테이블에서 조회합니다. + */ @Repository public interface MemberRepository extends JpaRepository { diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java new file mode 100644 index 00000000..f5c08dbe --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java @@ -0,0 +1,47 @@ +package com.ai.lawyer.domain.member.repositories; + +import com.ai.lawyer.domain.member.entity.Member; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; + +import jakarta.persistence.EntityManager; +import java.io.Serializable; + +/** + * MemberRepository의 커스텀 팩토리 빈 + * findById 호출을 가로채서 AuthUtil을 통해 처리하도록 합니다. + */ +public class MemberRepositoryFactoryBean, T, I extends Serializable> + extends JpaRepositoryFactoryBean { + + public MemberRepositoryFactoryBean(Class repositoryInterface) { + super(repositoryInterface); + } + + @NotNull + @Override + protected RepositoryFactorySupport createRepositoryFactory(@NotNull EntityManager entityManager) { + return new MemberRepositoryFactory(entityManager); + } + + private static class MemberRepositoryFactory extends JpaRepositoryFactory { + + public MemberRepositoryFactory(EntityManager entityManager) { + super(entityManager); + } + + @NotNull + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + // Member 엔티티인 경우 커스텀 베이스 클래스 사용 + if (Member.class.isAssignableFrom(metadata.getDomainType())) { + return SmartMemberRepositoryImpl.class; + } + return super.getRepositoryBaseClass(metadata); + } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java new file mode 100644 index 00000000..3caa99c1 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.ai.lawyer.domain.member.repositories; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.global.util.AuthUtil; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.persistence.EntityManager; +import java.util.Optional; + +/** + * MemberRepository의 커스텀 베이스 구현체 + * findById 호출 시 AuthUtil을 통해 적절한 테이블에서 조회합니다. + */ +public class SmartMemberRepositoryImpl extends SimpleJpaRepository { + + private static final Logger log = LoggerFactory.getLogger(SmartMemberRepositoryImpl.class); + + private final JpaEntityInformation entityInformation; + + public SmartMemberRepositoryImpl(JpaEntityInformation entityInformation, + EntityManager entityManager) { + super(entityInformation, entityManager); + this.entityInformation = entityInformation; + } + + @NotNull + @Override + public Optional findById(@NotNull ID id) { + // Member 엔티티이고 ID가 Long인 경우에만 AuthUtil 사용 + if (entityInformation.getJavaType().equals(Member.class) && id instanceof Long) { + try { + log.debug("SmartMemberRepositoryImpl.findById 호출: memberId={}", id); + Member member = AuthUtil.getMemberOrThrow((Long) id); + @SuppressWarnings("unchecked") + T result = (T) member; + return Optional.of(result); + } catch (ResponseStatusException e) { + log.debug("회원을 찾을 수 없음: memberId={}", id); + return Optional.empty(); + } + } + + // 다른 엔티티는 기본 동작 수행 + return super.findById(id); + } +} 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 1bc4f83e..37f59347 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 @@ -155,12 +155,7 @@ public ResponseEntity>> getClosedPolls() { public ResponseEntity> voteByIndex(@PathVariable Long pollId, @RequestParam int index) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Long memberId = Long.parseLong(authentication.getName()); - List options = pollService.getPollOptions(pollId); - if (index < 1 || index > options.size()) { - throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "index가 옵션 범위를 벗어났습니다."); - } - Long pollItemsId = options.get(index - 1).getPollItemsId(); - PollVoteDto result = pollService.vote(pollId, pollItemsId, memberId); + PollVoteDto result = pollService.voteByIndex(pollId, index, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java index a7c7e75d..558e472d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java @@ -1,11 +1,15 @@ package com.ai.lawyer.domain.poll.entity; -import com.ai.lawyer.domain.member.entity.Member; import jakarta.persistence.*; import lombok.*; @Entity -@Table(name = "poll_vote") +@Table(name = "poll_vote", + uniqueConstraints = @UniqueConstraint( + name = "uk_poll_vote_member_poll", + columnNames = {"member_id", "poll_id"} + ) +) @Data @NoArgsConstructor @AllArgsConstructor @@ -21,9 +25,11 @@ public class PollVote { @JoinColumn(name = "poll_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_POLL")) private Poll poll; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_MEMBER")) - private Member member; + // Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 + // 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장 + // foreignKey 제약조건 비활성화 (ConstraintMode.NO_CONSTRAINT) + @Column(name = "member_id", nullable = false) + private Long memberId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "poll_items_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_POLLOPTIONS")) 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 1f07ca49..4a893201 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 @@ -12,21 +12,20 @@ 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); - List findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); - List findByMember_MemberId(Long memberId); + Optional findByMemberIdAndPoll_PollId(Long memberId, Long pollId); + void deleteByMemberIdAndPoll_PollId(Long memberId, Long pollId); + List findByMemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); + List findByMemberId(Long memberId); /** * member_id로 투표 내역 삭제 (회원 탈퇴 시 사용) * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 */ @Modifying - @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") + @Query("DELETE FROM PollVote pv WHERE pv.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); - boolean existsByPollAndMember(Poll poll, Member member); - - @Query("SELECT v.member.memberId FROM PollVote v WHERE v.poll = :poll") + boolean existsByPollAndMemberId(Poll poll, Long memberId); + @Query("SELECT v.memberId FROM PollVote v WHERE v.poll = :poll") List findMemberIdsByPoll(@Param("poll") Poll poll); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java index dfefe91f..0a2f79a8 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java @@ -12,7 +12,10 @@ import org.springframework.stereotype.Repository; import com.querydsl.core.Tuple; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto.AgeGroupCountDto; import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto.GenderCountDto; @@ -91,23 +94,29 @@ public List countStaticsByPollOptionIds(List pollOptionIds pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPollItemsId().in(pollOptionIds)) .groupBy(pollOptions.getPollItemsId(), member.getGender(), member.getAge()) .fetch(); - return tuples.stream() - .map(t -> { - String gender = t.get(1, String.class); - Integer age = t.get(2, Integer.class); - String ageGroup = getAgeGroup(age); - Long voteCount = t.get(3, Long.class); - return PollStaticsDto.builder() - .gender(gender) - .ageGroup(ageGroup) - .voteCount(voteCount) - .build(); - }) - .toList(); + + // gender와 ageGroup별로 voteCount 합산 + Map staticsMap = new HashMap<>(); + for (Tuple t : tuples) { + Member.Gender genderEnum = t.get(1, Member.Gender.class); + String gender = genderEnum != null ? genderEnum.name() : "기타"; + Integer age = t.get(2, Integer.class); + String ageGroup = getAgeGroup(age); + Long voteCount = t.get(3, Long.class); + String key = gender + "_" + ageGroup; + staticsMap.put(key, staticsMap.getOrDefault(key, 0) + voteCount.intValue()); + } + + List result = new ArrayList<>(); + for (Map.Entry entry : staticsMap.entrySet()) { + String[] key = entry.getKey().split("_"); + result.add(new PollStaticsDto(key[0], key[1], entry.getValue().longValue())); + } + return result; } private String getAgeGroup(Integer age) { @@ -138,7 +147,7 @@ public List getOptionAgeStatics(Long pollId) { pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPoll().getPollId().eq(pollId)) .groupBy(pollOptions.getOption(), new com.querydsl.core.types.dsl.CaseBuilder() @@ -168,7 +177,7 @@ public List getOptionGenderStatics(Long pollId) { pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPoll().getPollId().eq(pollId)) .groupBy(pollOptions.getOption(), member.getGender()) .fetch(); 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 63e7853a..f4162297 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 @@ -28,6 +28,7 @@ public interface PollService { // ===== 투표 관련 ===== PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + PollVoteDto voteByIndex(Long pollId, int index, Long memberId); // ===== 투표 취소 관련 ===== void cancelVote(Long pollId, Long memberId); @@ -37,7 +38,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, Long memberId); + 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 fbc3488e..fe468f01 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,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; @@ -113,47 +111,67 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } - // 기존 투표 내역 조회 - var existingVoteOpt = pollVoteRepository.findByMember_MemberIdAndPoll_PollId(memberId, pollId); - if (existingVoteOpt.isPresent()) { - PollVote existingVote = existingVoteOpt.get(); - if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); - } else { - pollVoteRepository.deleteByMember_MemberIdAndPoll_PollId(memberId, pollId); - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .member(member) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표 항목을 변경하였습니다.") - .build(); + + try { + // 기존 투표 내역 조회 + var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); + if (existingVoteOpt.isPresent()) { + PollVote existingVote = existingVoteOpt.get(); + if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); + } else { + pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표 항목을 변경하였습니다.") + .build(); + } } + // 기존 투표 내역이 없으면 정상 투표 + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표가 완료되었습니다.") + .build(); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // 동시성 문제로 인한 중복 투표 시도 (unique constraint violation) + log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다."); } - // 기존 투표 내역이 없으면 정상 투표 - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .member(member) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표가 완료되었습니다.") - .build(); + } + + @Override + public PollVoteDto voteByIndex(Long pollId, int index, Long memberId) { + List options = getPollOptions(pollId); + if (options == null || options.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목이 존재하지 않습니다."); + } + if (index < 1 || index > options.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "index가 옵션 범위를 벗어났습니다."); + } + Long pollItemsId = options.get(index - 1).getPollItemsId(); + return vote(pollId, pollItemsId, memberId); } @Override @@ -220,6 +238,8 @@ public void closePoll(Long pollId) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); poll.setStatus(Poll.PollStatus.CLOSED); poll.setClosedAt(java.time.LocalDateTime.now()); + //예약 종료 시간도 현재 종료로 바꿈 추후 삭제 + poll.setReservedCloseAt(java.time.LocalDateTime.now()); pollRepository.save(poll); } @@ -227,7 +247,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가 있으면 연결 해제 @@ -297,7 +317,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) { @@ -428,7 +448,7 @@ private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); boolean voted = false; if (memberId != null) { - voted = !pollVoteRepository.findByMember_MemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); + voted = !pollVoteRepository.findByMemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); } List statics = null; if (withStatistics && poll.getStatus() == Poll.PollStatus.CLOSED) { @@ -526,7 +546,7 @@ public void validatePollCreate(PollCreateDto dto) { @Override public void cancelVote(Long pollId, Long memberId) { - pollVoteRepository.findByMember_MemberIdAndPoll_PollId(memberId, pollId) + pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId) .ifPresent(pollVoteRepository::delete); } } 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 38e00a6b..fd860c2c 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 @@ -114,6 +114,15 @@ public ResponseEntity> deletePost(@PathVariable Long postId) { return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); } + @Operation(summary = "게시글 삭제(관리자)") + @DeleteMapping("/admin/{postId}") + public ResponseEntity> deletePostAdmin(@PathVariable Long postId) { + //AuthUtil.validateAdmin(); 관리자 + AuthUtil.getAuthenticatedMemberId(); // 모든 유저 + postService.deletePostAdmin(postId); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity> handleResponseStatusException(ResponseStatusException ex) { int code = ex.getStatusCode().value(); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java index 170c4855..7e1a1b25 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java @@ -38,4 +38,20 @@ public ResponseEntity deleteDummyMembers() { int deleted = dummyService.deleteDummyMembers(); return ResponseEntity.ok("더미 멤버 " + deleted + "명 삭제 완료"); } + + //모든 더미 유저가 1번 옵션에 투표 + @Operation(summary = "더미 멤버 1번 투표") + @PostMapping("/vote1") + public ResponseEntity dummyVote1Option(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote1Option(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } + + //모든 더미 유저가 2번 옵션에 투표 + @Operation(summary = "더미 멤버 2번 투표") + @PostMapping("/vote2") + public ResponseEntity dummyVote2Option(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote2Option(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java b/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java index 74b2f23a..7469f4c1 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java @@ -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.*; @@ -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; 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 db085a25..afe741b1 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 @@ -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; @@ -15,17 +14,18 @@ @Repository public interface PostRepository extends JpaRepository { - List findByMember(Member member); + // member_id로 직접 조회 (Member, OAuth2Member 모두 지원) + List 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 findByMember(Member member, Pageable pageable); + Page findByMemberId(Long memberId, Pageable pageable); Page findByPoll_Status(PollStatus status, Pageable pageable); Page findByPoll_StatusAndPoll_PollIdIn(PollStatus status, List pollIds, Pageable pageable); Page findByPoll_PollIdIn(List pollIds, Pageable pageable); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java index c0348ec5..9e83c5e0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java @@ -104,7 +104,7 @@ public int dummyVote(Long postId) { PollOptions selectedOption = pollOptionsList.get(random.nextInt(pollOptionsList.size())); PollVote pollVote = PollVote.builder() .poll(post.getPoll()) - .member(member) + .memberId(member.getMemberId()) .pollOptions(selectedOption) .build(); pollVoteRepository.save(pollVote); @@ -114,6 +114,70 @@ public int dummyVote(Long postId) { return voteCount; } + /** + * 모든 더미 유저가 1번 옵션에 투표 + */ + @Transactional + public int dummyVote1Option(Long postId) { + Optional postOpt = postRepository.findById(postId); + if (postOpt.isEmpty()) return 0; + Post post = postOpt.get(); + if (post.getPoll() == null) return 0; + List pollOptionsList = pollOptionsRepository.findByPoll_PollId(post.getPoll().getPollId()); + if (pollOptionsList.size() < 1) return 0; + PollOptions firstOption = pollOptionsList.get(0); + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + List votedMemberIds = pollVoteRepository.findMemberIdsByPoll(post.getPoll()); + Set votedMemberIdSet = new HashSet<>(votedMemberIds); + int voteCount = 0; + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .memberId(member.getMemberId()) + .pollOptions(firstOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + + /** + * 모든 더미 유저가 2번 옵션에 투표 + */ + @Transactional + public int dummyVote2Option(Long postId) { + Optional postOpt = postRepository.findById(postId); + if (postOpt.isEmpty()) return 0; + Post post = postOpt.get(); + if (post.getPoll() == null) return 0; + List pollOptionsList = pollOptionsRepository.findByPoll_PollId(post.getPoll().getPollId()); + if (pollOptionsList.size() < 2) return 0; + PollOptions secondOption = pollOptionsList.get(1); + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + List votedMemberIds = pollVoteRepository.findMemberIdsByPoll(post.getPoll()); + Set votedMemberIdSet = new HashSet<>(votedMemberIds); + int voteCount = 0; + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .memberId(member.getMemberId()) + .pollOptions(secondOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + @Transactional public int deleteDummyMembers() { List dummyMembers = memberRepository.findAll().stream() 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 65860dea..b87dde08 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 @@ -26,6 +26,7 @@ public interface PostService { PostDto updatePost(Long postId, PostUpdateDto postUpdateDto); void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); void deletePost(Long postId); + void deletePostAdmin(Long postId); PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, 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 2024b2e6..7c0dc5ec 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 @@ -1,6 +1,5 @@ package com.ai.lawyer.domain.post.service; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.poll.dto.PollDto.PollStatus; import com.ai.lawyer.domain.post.dto.PostDto; @@ -21,7 +20,6 @@ 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; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,9 +62,11 @@ public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); } - Member member = AuthUtil.getMemberOrThrow(memberId); + // 회원 존재 여부 확인 (Member 또는 OAuth2Member) + AuthUtil.getMemberOrThrow(memberId); + Post post = Post.builder() - .member(member) + .memberId(memberId) .postName(postRequestDto.getPostName()) .postContent(postRequestDto.getPostContent()) .category(postRequestDto.getCategory()) @@ -90,7 +90,7 @@ public PostDetailDto getPostDetailById(Long postId, Long memberId) { public PostDetailDto getPostById(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - PostDto postDto = convertToDto(post, post.getMember().getMemberId()); + PostDto postDto = convertToDto(post, post.getMemberId()); return PostDetailDto.builder() .post(postDto) .build(); @@ -98,11 +98,12 @@ public PostDetailDto getPostById(Long postId) { @Override public List getPostsByMemberId(Long memberId) { - Member member = AuthUtil.getMemberOrThrow(memberId); - List posts = postRepository.findByMember(member); - if (posts.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); - } + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(memberId); + List posts = postRepository.findByMemberId(memberId); +// if (posts.isEmpty()) { +// throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); +// } return posts.stream() .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) // 최신순 정렬 .map(post -> convertToDto(post, memberId)) @@ -127,7 +128,7 @@ public PostDto updatePost(Long postId, PostUpdateDto postUpdateDto) { if (post.getPoll() == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "이 게시글에는 투표가 없어 투표 수정이 불가능합니다."); } - pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll(), post.getMember().getMemberId()); + pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll(), post.getMemberId()); } if (postUpdateDto.getPostName() != null) post.setPostName(postUpdateDto.getPostName()); @@ -135,7 +136,7 @@ public PostDto updatePost(Long postId, PostUpdateDto postUpdateDto) { if (postUpdateDto.getCategory() != null) post.setCategory(postUpdateDto.getCategory()); post.setUpdatedAt(LocalDateTime.now()); // 추가 postRepository.save(post); - return convertToDto(post, post.getMember().getMemberId()); + return convertToDto(post, post.getMemberId()); } @Override @@ -145,6 +146,13 @@ public void deletePost(Long postId) { postRepository.delete(post); } + @Override + public void deletePostAdmin(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "삭제할 게시글을 찾을 수 없습니다.")); + postRepository.delete(post); + } + @Override public List getAllPosts(Long memberId) { return postRepository.findAll().stream() @@ -158,15 +166,16 @@ public List getAllPosts(Long memberId) { public PostDto getMyPostById(Long postId, Long requesterMemberId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - if (!post.getMember().getMemberId().equals(requesterMemberId)) { + if (!post.getMemberId().equals(requesterMemberId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인 게시글만 조회할 수 있습니다."); } return convertToDto(post, requesterMemberId); } public List getMyPosts(Long requesterMemberId) { - Member member = AuthUtil.getMemberOrThrow(requesterMemberId); - List posts = postRepository.findByMember(member); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(requesterMemberId); + List posts = postRepository.findByMemberId(requesterMemberId); return posts.stream() .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) .map(post -> convertToDto(post, requesterMemberId)) @@ -175,8 +184,9 @@ public List getMyPosts(Long requesterMemberId) { @Override public Page getMyPostspaged(Pageable pageable, Long requesterMemberId) { - Member member = AuthUtil.getMemberOrThrow(requesterMemberId); - Page posts = postRepository.findByMember(member, pageable); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(requesterMemberId); + Page posts = postRepository.findByMemberId(requesterMemberId, pageable); return posts.map(post -> convertToDto(post, requesterMemberId)); } @@ -211,9 +221,10 @@ public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId } var pollDto = dto.getPoll(); pollService.validatePollCreate(pollDto); - Member member = AuthUtil.getMemberOrThrow(memberId); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() - .member(member) + .memberId(memberId) .postName(postDto.getPostName()) .postContent(postDto.getPostContent()) .category(postDto.getCategory()) @@ -256,7 +267,7 @@ public List getAllSimplePosts() { } return PostSimpleDto.builder() .postId(post.getPostId()) - .memberId(post.getMember().getMemberId()) + .memberId(post.getMemberId()) .poll(pollInfo) .build(); }) @@ -289,7 +300,7 @@ public Page getClosedPostsPaged(Pageable pageable, Long memberId) { } private Page getMyVotedPostsPagedByStatus(Pageable pageable, Long memberId, Poll.PollStatus status) { - List votes = pollVoteRepository.findByMember_MemberId(memberId); + List votes = pollVoteRepository.findByMemberId(memberId); List pollIds = votes.stream().map(v -> v.getPoll().getPollId()).distinct().toList(); Page posts = (status == null) ? postRepository.findByPoll_PollIdIn(pollIds, pageable) @@ -332,10 +343,7 @@ public List getTopNPollsByStatus(PollStatus status, int n, Long memberI } private PostDto convertToDto(Post entity, Long memberId) { - Long postMemberId = null; - if (entity.getMember() != null) { - postMemberId = entity.getMember().getMemberId(); - } + Long postMemberId = entity.getMemberId(); PollDto pollDto = null; if (entity.getPoll() != null) { if (entity.getPoll().getStatus() == Poll.PollStatus.CLOSED) { diff --git a/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java index dec3fccd..de244666 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java @@ -1,5 +1,6 @@ package com.ai.lawyer.global.config; +import com.ai.lawyer.domain.member.repositories.MemberRepositoryFactoryBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; @@ -18,7 +19,8 @@ @EnableJpaRepositories( basePackages = "com.ai.lawyer.domain.*", entityManagerFactoryRef = "dataEntityManager", - transactionManagerRef = "dataTransactionManager" + transactionManagerRef = "dataTransactionManager", + repositoryFactoryBeanClass = MemberRepositoryFactoryBean.class ) public class DataDBConfig { @@ -49,7 +51,7 @@ public LocalContainerEntityManagerFactoryBean dataEntityManager() { LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); em.setDataSource(dataDBSource()); - em.setPackagesToScan(new String[]{"com.ai.lawyer.domain.*"}); + em.setPackagesToScan("com.ai.lawyer.domain.*"); em.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); HashMap properties = new HashMap<>(); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index 8de82fd1..ba29ff5d 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -26,14 +26,18 @@ public class CookieUtil { // 쿠키 보안 설정 상수 private static final boolean HTTP_ONLY = true; - private static final boolean SECURE_IN_PRODUCTION = false; // 개발환경에서는 false (HTTP), 운영환경에서는 true로 변경 (HTTPS) private static final String COOKIE_PATH = "/"; - private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용 private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; @Value("${custom.cookie.domain:}") private String cookieDomain; + @Value("${custom.cookie.secure:false}") + private boolean cookieSecure; + + @Value("${custom.cookie.same-site:Lax}") + private String cookieSameSite; + public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { setAccessTokenCookie(response, accessToken); setRefreshTokenCookie(response, refreshToken); @@ -58,26 +62,26 @@ public void clearTokenCookies(HttpServletResponse response) { * ResponseCookie를 생성합니다 (SameSite 지원). */ private ResponseCookie createResponseCookie(String name, String value, int maxAge) { - log.debug("=== 쿠키 생성 중: name={}, cookieDomain='{}', isEmpty={}", - name, cookieDomain, cookieDomain == null || cookieDomain.isEmpty()); + log.info("=== 쿠키 생성 중: name={}, domain='{}', secure={}, sameSite={}", + name, cookieDomain, cookieSecure, cookieSameSite); ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) .httpOnly(HTTP_ONLY) - .secure(SECURE_IN_PRODUCTION) + .secure(cookieSecure) .path(COOKIE_PATH) .maxAge(Duration.ofSeconds(maxAge)) - .sameSite(SAME_SITE); + .sameSite(cookieSameSite); // 도메인이 설정되어 있으면 추가 if (cookieDomain != null && !cookieDomain.isEmpty()) { - log.debug("쿠키 도메인 설정: {}", cookieDomain); + log.info("쿠키 도메인 설정: {}", cookieDomain); builder.domain(cookieDomain); } else { - log.debug("쿠키 도메인 설정 안 함 (빈 값 또는 null)"); + log.info("쿠키 도메인 설정 안 함 (빈 값 또는 null)"); } ResponseCookie cookie = builder.build(); - log.debug("생성된 쿠키: {}", cookie); + log.info("생성된 쿠키: {}", cookie); return cookie; } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index a7a8978d..a7d9ba90 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -162,6 +162,7 @@ private void setAuthentication(String token) { Long memberId = tokenProvider.getMemberIdFromToken(token); String loginId = tokenProvider.getLoginIdFromToken(token); String role = tokenProvider.getRoleFromToken(token); + String loginType = tokenProvider.getLoginTypeFromToken(token); if (memberId == null) { log.warn(LOG_MEMBER_ID_EXTRACTION_FAILED); @@ -174,7 +175,7 @@ private void setAuthentication(String token) { // memberId를 principal로 하는 인증 객체 생성 // getName()은 memberId를 반환 (PollController 호환) - // getDetails()는 loginId를 반환 (MemberController 호환) + // getDetails()는 loginId와 loginType을 포함한 맵을 반환 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(memberId, null, authorities) { @Override @@ -184,11 +185,15 @@ public String getName() { @Override public Object getDetails() { - return loginId; + return java.util.Map.of( + "loginId", loginId != null ? loginId : "", + "loginType", loginType != null ? loginType : "LOCAL" + ); } }; SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("JWT 인증 설정 완료: memberId={}, loginId={}, loginType={}", memberId, loginId, loginType); } catch (Exception e) { log.warn(LOG_SET_AUTH_FAILED, e.getMessage()); } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index 88ac99a7..5a819ce4 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -37,6 +37,7 @@ public class TokenProvider { private static final String CLAIM_LOGIN_ID = "loginId"; private static final String CLAIM_MEMBER_ID = "memberId"; private static final String CLAIM_ROLE = "role"; + private static final String CLAIM_LOGIN_TYPE = "loginType"; // 로그 메시지 상수 private static final String LOG_ACCESS_TOKEN_SAVED = "=== Access token Hash 저장 성공: key={}, expiry={} ==="; @@ -60,12 +61,16 @@ public String generateAccessToken(com.ai.lawyer.domain.member.entity.MemberAdapt Date now = new Date(); Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * MILLIS_PER_SECOND); + // 로그인 타입 결정 (OAuth2Member인지 Member인지 확인) + String loginType = (member instanceof com.ai.lawyer.domain.member.entity.OAuth2Member) ? "OAUTH2" : "LOCAL"; + String accessToken = Jwts.builder() .setIssuedAt(now) .setExpiration(expiry) .claim(CLAIM_LOGIN_ID, member.getLoginId()) .claim(CLAIM_MEMBER_ID, member.getMemberId()) .claim(CLAIM_ROLE, member.getRole().name()) + .claim(CLAIM_LOGIN_TYPE, loginType) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); @@ -176,6 +181,10 @@ public String getLoginIdFromToken(String token) { return getClaimFromToken(token, CLAIM_LOGIN_ID, String.class, LOG_LOGIN_ID_EXTRACTION_FAILED); } + public String getLoginTypeFromToken(String token) { + return getClaimFromToken(token, CLAIM_LOGIN_TYPE, String.class, "토큰에서 로그인 타입 추출 실패: {}"); + } + /** * 토큰에서 특정 Claim을 추출하는 공통 메서드 */ diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java index 251dc484..c3b3e415 100644 --- a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java @@ -48,10 +48,12 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic member = createOAuth2Member(userInfo); } else { // 기존 OAuth2 회원 로그인 - log.info("기존 OAuth2 사용자 로그인: email={}, provider={}", userInfo.getEmail(), registrationId); + log.info("기존 OAuth2 사용자 로그인: email={}, provider={}, memberId={}", userInfo.getEmail(), registrationId, member.getMemberId()); } - oauth2MemberRepository.save(member); + // 엔티티를 저장하고 영속화된 엔티티를 반환받아야 memberId가 할당됨 + member = oauth2MemberRepository.save(member); + log.info("OAuth2 회원 저장 완료: memberId={}, loginId={}", member.getMemberId(), member.getLoginId()); // OAuth2 provider의 access token을 Redis에 저장 (연동 해제용) saveOAuth2ProviderAccessToken(userInfo.getEmail(), accessToken); diff --git a/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java b/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java index 9330f5c7..61f8f248 100644 --- a/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java +++ b/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import java.util.concurrent.ExecutionException; @@ -12,6 +13,7 @@ @Slf4j @Component @RequiredArgsConstructor +@Profile("!test") // test 프로파일에서는 비활성화 public class QdrantInitializer { private final QdrantClient qdrantClient; diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 896624f5..12a1e05b 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -56,8 +56,8 @@ public class SecurityConfig { "/api/precedent/**", // 판례 (공개) "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개) - "/api/chat/**", // 챗봇 (공개) "/api/home/**", // 홈 (공개) + "/api/chat/**", // 챗봇 "/h2-console/**", // H2 콘솔 (개발용) "/actuator/health", "/actuator/health/**", "/actuator/info", // Spring Actuator "/api/actuator/health", "/api/actuator/health/**", "/api/actuator/info", 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 aa8a7a66..bbdebe94 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 @@ -1,22 +1,22 @@ package com.ai.lawyer.global.util; +import jakarta.persistence.EntityManager; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; 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; + private static EntityManager entityManager; @Autowired - public AuthUtil(MemberRepository memberRepository) { - AuthUtil.memberRepository = memberRepository; + public AuthUtil(EntityManager entityManager) { + AuthUtil.entityManager = entityManager; } public static Long getCurrentMemberId() { @@ -24,20 +24,26 @@ public static Long getCurrentMemberId() { if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); System.out.println("[AuthUtil] principal class: " + principal.getClass().getName() + ", value: " + principal); - if (principal instanceof org.springframework.security.core.userdetails.User user) { - try { - return Long.parseLong(user.getUsername()); - } catch (NumberFormatException e) { - return null; + switch (principal) { + case org.springframework.security.core.userdetails.User user -> { + try { + return Long.parseLong(user.getUsername()); + } catch (NumberFormatException e) { + return null; + } } - } else if (principal instanceof String str) { - try { - return Long.parseLong(str); - } catch (NumberFormatException e) { - return null; + case String str -> { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return null; + } + } + case Long l -> { + return l; + } + default -> { } - } else if (principal instanceof Long l) { - return l; } } return null; @@ -50,13 +56,112 @@ public static String getCurrentMemberRole() { } return authentication.getAuthorities().stream() .findFirst() - .map(auth -> auth.getAuthority()) + .map(GrantedAuthority::getAuthority) .orElse(null); } + /** + * 현재 인증된 사용자의 로그인 타입을 가져옵니다. + * @return "LOCAL" 또는 "OAUTH2", 없으면 null + */ + public static String getCurrentLoginType() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object details = authentication.getDetails(); + if (details instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map detailsMap = (java.util.Map) details; + return detailsMap.get("loginType"); + } + + return null; + } + + /** + * memberId로 회원을 조회합니다. (Member 또는 OAuth2Member) + * SecurityContext에서 현재 인증된 사용자의 loginType을 자동으로 확인하여 적절한 테이블에서 조회합니다. + * OAuth2Member인 경우 Member 객체로 변환하여 반환합니다. + * EntityManager를 직접 사용하여 무한 루프를 방지합니다. + * @param memberId 회원 ID + * @return Member 객체 + * @throws ResponseStatusException 회원을 찾을 수 없는 경우 + */ public static Member getMemberOrThrow(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); + // SecurityContext에서 loginType 자동 추출 + String loginType = getCurrentLoginType(); + + // loginType이 있으면 해당 테이블에서만 조회 (성능 최적화) + if (loginType != null) { + return getMemberOrThrow(memberId, loginType); + } + + // loginType이 없으면 하위 호환성을 위해 두 테이블 모두 조회 + // 먼저 Member 테이블에서 조회 (EntityManager 직접 사용) + Member member = entityManager.find(Member.class, memberId); + if (member != null) { + return member; + } + + // Member 테이블에 없으면 OAuth2Member 테이블에서 조회 (EntityManager 직접 사용) + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + entityManager.find(com.ai.lawyer.domain.member.entity.OAuth2Member.class, memberId); + if (oauth2Member != null) { + // OAuth2Member를 Member로 변환 (엔티티 호환성을 위해) + return Member.builder() + .memberId(oauth2Member.getMemberId()) + .loginId(oauth2Member.getLoginId()) + .name(oauth2Member.getName()) + .age(oauth2Member.getAge()) + .gender(oauth2Member.getGender()) + .role(oauth2Member.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); + } + + // 둘 다 없으면 예외 발생 + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다"); + } + + /** + * memberId와 loginType으로 회원을 조회합니다. + * loginType이 "LOCAL"이면 Member 테이블에서, "OAUTH2"이면 OAuth2Member 테이블에서 조회합니다. + * EntityManager를 직접 사용하여 무한 루프를 방지합니다. + * @param memberId 회원 ID + * @param loginType 로그인 타입 ("LOCAL" 또는 "OAUTH2") + * @return Member 객체 + * @throws ResponseStatusException 회원을 찾을 수 없는 경우 + */ + public static Member getMemberOrThrow(Long memberId, String loginType) { + if ("OAUTH2".equals(loginType)) { + // OAuth2 회원 조회 (EntityManager 직접 사용) + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + entityManager.find(com.ai.lawyer.domain.member.entity.OAuth2Member.class, memberId); + + if (oauth2Member != null) { + // OAuth2Member를 Member로 변환 + return Member.builder() + .memberId(oauth2Member.getMemberId()) + .loginId(oauth2Member.getLoginId()) + .name(oauth2Member.getName()) + .age(oauth2Member.getAge()) + .gender(oauth2Member.getGender()) + .role(oauth2Member.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); + } + } else { + // LOCAL 회원 조회 (EntityManager 직접 사용) + Member member = entityManager.find(Member.class, memberId); + if (member != null) { + return member; + } + } + + // 찾지 못한 경우 예외 발생 + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다"); } public static Long getAuthenticatedMemberId() { diff --git a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java index e5b53c43..ca548061 100644 --- a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java @@ -19,7 +19,6 @@ import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.MessageType; import org.springframework.beans.factory.annotation.Value; -import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,7 +46,7 @@ public class ChatPostProcessingConsumer { @Value("${custom.ai.keyword-extraction}") private String keywordExtraction; - @KafkaListener(topics = "chat-post-processing", groupId = "chat-processing-group") + //@KafkaListener(topics = "chat-post-processing", groupId = "chat-processing-group") @Transactional public void consume(ChatPostProcessEvent event) { try { diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index efdd5128..fb41d616 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -17,18 +17,6 @@ spring: password: ${DEV_REDIS_PASSWORD} embedded: false - kafka: - bootstrap-servers: localhost:9092 - producer: - key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - consumer: - group-id: chat-processing-group # 컨슈머 그룹 ID - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer - properties: - spring.json.trusted.packages: "*" - batch: job: enabled: false # 최소 한번 시작 @@ -71,4 +59,6 @@ custom: frontend: url: ${DEV_FRONTEND_URL} cookie: - domain: ${DEV_COOKIE_DOMAIN} + domain: ${DEV_COOKIE_DOMAIN:} # 개발환경: 도메인 설정 없음 (localhost) + secure: false # HTTP 환경 (localhost) + same-site: Lax # 개발환경에서는 Lax로 충분 diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 9fa63b62..0f0d51e5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -17,6 +17,7 @@ spring: connection-timeout: 30000 max-lifetime: 1800000 # MySQL wait_timeout(기본 28800)보다 짧게 jpa: + show-sql: false open-in-view: false # 프로덕션 권장 hibernate: ddl-auto: ${PROD_JPA_HIBERNATE_DDL_AUTO} # 운영 DB는 보통 validate (또는 none) @@ -83,9 +84,19 @@ custom: url: ${PROD_FRONTEND_URL} cookie: domain: ${PROD_COOKIE_DOMAIN:.trybalaw.com} # 운영환경: 모든 서브도메인에서 쿠키 공유 + secure: true # HTTPS 환경에서는 반드시 true + same-site: None # 크로스 도메인 쿠키 전송 허용 (api.trybalaw.com <-> www.trybalaw.com) sentry: dsn: ${PROD_SENTRY_DSN} environment: "prod" release: "my-app@0.1.0-prod" - send-default-pii: true \ No newline at end of file + send-default-pii: true + +logging: + level: + org.hibernate.SQL: WARN # SQL 문 로그 줄이기 + org.hibernate.orm.jdbc.bind: OFF # 바인딩 파라미터 로그 끔(Hibernate 6) + org.hibernate.type.descriptor.jdbc: OFF + org.springframework.jdbc.core: ERROR # JdbcTemplate 디버그 억제 + com.zaxxer.hikari: INFO # 커넥션 풀 상태만 간결하게 \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 1e2e2581..8badc760 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -61,10 +61,10 @@ spring: jdbc: initialize-schema: never - jpa: - show-sql: true - hibernate: - ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} +# jpa: +# show-sql: true +# hibernate: +# ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} properties: hibernate: diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java index 7d6b8f94..79c2d581 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java @@ -63,7 +63,7 @@ void autoCloseTest() { post.setPostContent("테스트 내용"); post.setCategory("테스트"); post.setCreatedAt(LocalDateTime.now()); - post.setMember(member); + post.setMemberId(member.getMemberId()); post.setPoll(null); lenient().when(postRepository.save(any(Post.class))).thenReturn(post); @@ -80,7 +80,7 @@ void autoCloseTest() { postWithPoll.setPostContent("테스트 내용"); postWithPoll.setCategory("테스트"); postWithPoll.setCreatedAt(post.getCreatedAt()); - postWithPoll.setMember(member); + postWithPoll.setMemberId(member.getMemberId()); postWithPoll.setPoll(poll); lenient().when(postRepository.save(argThat(p -> p.getPoll() != null))).thenReturn(postWithPoll); diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java index 739b6371..14b1336e 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.Logger; @@ -32,7 +31,6 @@ class CookieUtilTest { @Mock private HttpServletResponse response; - @InjectMocks private CookieUtil cookieUtil; private static final String ACCESS_TOKEN = "testAccessToken"; @@ -43,6 +41,12 @@ class CookieUtilTest { @BeforeEach void setUp() { log.info("=== 테스트 초기화 ==="); + cookieUtil = new CookieUtil(); + // 테스트 환경 설정: 개발 환경 (HTTP, SameSite=Lax) + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieDomain", ""); + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSecure", false); + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSameSite", "Lax"); + log.info("CookieUtil 설정 완료: domain='', secure=false, sameSite=Lax"); } @Test @@ -330,4 +334,45 @@ void cookieMaxAgeAttribute_ExpiryTime() { log.info("=== 토큰 만료 시간 테스트 완료 ==="); } + + @Test + @DisplayName("프로덕션 환경 - Secure=true, SameSite=None, Domain 설정") + void productionCookieSettings() { + // given + log.info("=== 프로덕션 환경 쿠키 설정 테스트 시작 ==="); + CookieUtil prodCookieUtil = new CookieUtil(); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieDomain", ".trybalaw.com"); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSecure", true); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSameSite", "None"); + log.info("프로덕션 설정: domain=.trybalaw.com, secure=true, sameSite=None"); + + // when + prodCookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + + // then + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + var setCookieHeaders = headerCaptor.getAllValues(); + + // 액세스 토큰 쿠키 검증 + String accessCookieHeader = setCookieHeaders.getFirst(); + assertThat(accessCookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); + assertThat(accessCookieHeader).contains("HttpOnly"); + assertThat(accessCookieHeader).contains("Secure"); + assertThat(accessCookieHeader).contains("Domain=.trybalaw.com"); + assertThat(accessCookieHeader).contains("SameSite=None"); + log.info("프로덕션 액세스 토큰 쿠키 검증 완료: {}", accessCookieHeader); + + // 리프레시 토큰 쿠키 검증 + String refreshCookieHeader = setCookieHeaders.get(1); + assertThat(refreshCookieHeader).contains(REFRESH_TOKEN_NAME + "=" + REFRESH_TOKEN); + assertThat(refreshCookieHeader).contains("HttpOnly"); + assertThat(refreshCookieHeader).contains("Secure"); + assertThat(refreshCookieHeader).contains("Domain=.trybalaw.com"); + assertThat(refreshCookieHeader).contains("SameSite=None"); + log.info("프로덕션 리프레시 토큰 쿠키 검증 완료: {}", refreshCookieHeader); + + log.info("=== 프로덕션 환경 쿠키 설정 테스트 완료 ==="); + } } diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java index 1894ce91..cd129a44 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java @@ -118,6 +118,7 @@ void generateAccessToken_Success() { assertThat(claims.get("loginId", String.class)).as("loginId claim 일치").isEqualTo("test@example.com"); assertThat(claims.get("memberId", Long.class)).as("memberId claim 일치").isEqualTo(1L); assertThat(claims.get("role", String.class)).as("role claim 일치").isEqualTo("USER"); + assertThat(claims.get("loginType", String.class)).as("loginType claim 일치").isEqualTo("LOCAL"); assertThat(claims.getIssuedAt()).as("발급 시간 존재").isNotNull(); assertThat(claims.getExpiration()).as("만료 시간 존재").isNotNull(); assertThat(claims.getExpiration()).as("만료 시간이 발급 시간 이후").isAfter(claims.getIssuedAt()); @@ -571,6 +572,80 @@ void deleteAllTokens_Success() { log.info("=== 모든 토큰 삭제 테스트 완료 ==="); } + @Test + @DisplayName("토큰에서 loginType 추출 성공 - LOCAL") + void getLoginTypeFromToken_Success_Local() { + // given + log.info("=== 토큰에서 loginType 추출 테스트 시작 (LOCAL) ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("토큰 생성 완료"); + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(token); + log.info("loginType 추출 완료: {}", loginType); + + // then + assertThat(loginType).as("loginType이 null이 아님").isNotNull(); + assertThat(loginType).as("loginType 일치").isEqualTo("LOCAL"); + log.info("=== 토큰에서 loginType 추출 테스트 완료 (LOCAL) ==="); + } + + @Test + @DisplayName("토큰에서 loginType 추출 성공 - OAUTH2") + void getLoginTypeFromToken_Success_OAuth2() { + // given + log.info("=== 토큰에서 loginType 추출 테스트 시작 (OAUTH2) ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + com.ai.lawyer.domain.member.entity.OAuth2Member.builder() + .memberId(2L) + .loginId("oauth@example.com") + .email("oauth@example.com") + .name("OAuth User") + .age(30) + .gender(Member.Gender.MALE) + .provider(com.ai.lawyer.domain.member.entity.OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + + String token = tokenProvider.generateAccessToken(oauth2Member); + log.info("OAuth2 토큰 생성 완료"); + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(token); + log.info("loginType 추출 완료: {}", loginType); + + // then + assertThat(loginType).as("loginType이 null이 아님").isNotNull(); + assertThat(loginType).as("loginType 일치").isEqualTo("OAUTH2"); + log.info("=== 토큰에서 loginType 추출 테스트 완료 (OAUTH2) ==="); + } + + @Test + @DisplayName("토큰에서 loginType 추출 실패 - 유효하지 않은 토큰") + void getLoginTypeFromToken_Fail_InvalidToken() { + // given + log.info("=== 토큰에서 loginType 추출 실패 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(invalidToken); + log.info("loginType 추출 결과: {}", loginType); + + // then + assertThat(loginType).as("유효하지 않은 토큰에서는 null 반환").isNull(); + log.info("=== 토큰에서 loginType 추출 실패 테스트 완료 ==="); + } + @Test @DisplayName("여러 사용자의 토큰 생성 및 검증 - 멀티 유저 시나리오") void multipleUsers_TokenGeneration() { diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java index b8877181..bb7ef000 100644 --- a/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java @@ -96,4 +96,22 @@ private Map createNaverAttributes() { return attributes; } + + @Test + @DisplayName("OAuth2 회원 저장 후 반환된 엔티티 사용 - memberId 할당 확인") + void oauth2MemberSave_ReturnsEntityWithMemberId() { + // given - 이 테스트는 CustomOAuth2UserService에서 save() 반환값을 사용하는지 검증 + // 실제 구현에서는 다음과 같이 수정되어야 함: + // member = oauth2MemberRepository.save(member); + + // when - save() 호출 시 memberId가 할당된 엔티티가 반환됨 + // JPA의 @GeneratedValue 전략 사용 시, save()는 영속화된 엔티티를 반환하며 + // 이 엔티티에는 자동 생성된 ID가 포함되어 있음 + + // then - 반환된 엔티티의 memberId를 사용해야 JWT 토큰 생성 시 올바른 ID가 포함됨 + // 이를 통해 소셜 로그인 후 API 호출 시 member_id 조회가 정상 동작함 + + // 이 테스트는 문서화 목적으로, 실제 동작은 Integration Test에서 검증됨 + assertThat(true).isTrue(); // 개념 검증용 테스트 + } } diff --git a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java new file mode 100644 index 00000000..da84eeb7 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java @@ -0,0 +1,251 @@ +package com.ai.lawyer.global.util; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthUtil 테스트") +class AuthUtilTest { + + @Mock + private EntityManager entityManager; + + private Member localMember; + private OAuth2Member oauth2Member; + + @BeforeEach + void setUp() { + // AuthUtil의 static EntityManager를 초기화 + // 반환값은 사용하지 않지만 static 필드 설정을 위해 생성자 호출 필요 + @SuppressWarnings("unused") + AuthUtil authUtil = new AuthUtil(entityManager); + + localMember = Member.builder() + .memberId(1L) + .loginId("local@test.com") + .password("encodedPassword") + .name("로컬사용자") + .age(30) + .gender(Member.Gender.MALE) + .role(Member.Role.USER) + .build(); + + oauth2Member = OAuth2Member.builder() + .memberId(2L) + .loginId("oauth@test.com") + .email("oauth@test.com") + .name("소셜사용자") + .age(25) + .gender(Member.Gender.FEMALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + } + + @Test + @DisplayName("로컬 회원 조회 성공") + void getMemberOrThrow_LocalMember_Success() { + // given + Long memberId = 1L; + given(entityManager.find(Member.class, memberId)).willReturn(localMember); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + assertThat(result.getName()).isEqualTo("로컬사용자"); + + verify(entityManager).find(Member.class, memberId); + } + + @Test + @DisplayName("OAuth2 회원 조회 성공 - Member 테이블에 없을 때") + void getMemberOrThrow_OAuth2Member_Success() { + // given + Long memberId = 2L; + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getLoginId()).isEqualTo("oauth@test.com"); + assertThat(result.getName()).isEqualTo("소셜사용자"); + assertThat(result.getAge()).isEqualTo(25); + assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); + assertThat(result.getRole()).isEqualTo(Member.Role.USER); + + verify(entityManager).find(Member.class, memberId); + verify(entityManager).find(OAuth2Member.class, memberId); + } + + @Test + @DisplayName("OAuth2 회원을 Member로 변환 - 비밀번호는 빈 문자열") + void getMemberOrThrow_OAuth2Member_NoPassword() { + // given + Long memberId = 2L; + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result.getPassword()).isEqualTo(""); + } + + @Test + @DisplayName("회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_MemberNotFound_ThrowsException() { + // given + Long memberId = 999L; + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(entityManager).find(Member.class, memberId); + verify(entityManager).find(OAuth2Member.class, memberId); + } + + @Test + @DisplayName("로컬 회원 우선 조회 - 양쪽 테이블에 같은 ID가 있을 때") + void getMemberOrThrow_PrioritizeLocalMember() { + // given + Long memberId = 1L; + given(entityManager.find(Member.class, memberId)).willReturn(localMember); + // OAuth2 Member는 조회되지 않아야 함 + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + + verify(entityManager).find(Member.class, memberId); + // OAuth2Member는 조회되지 않음을 검증 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(OAuth2Member.class, memberId); + } + + @Test + @DisplayName("loginType으로 로컬 회원 조회 성공") + void getMemberOrThrow_WithLoginType_Local_Success() { + // given + Long memberId = 1L; + String loginType = "LOCAL"; + given(entityManager.find(Member.class, memberId)).willReturn(localMember); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + assertThat(result.getName()).isEqualTo("로컬사용자"); + + verify(entityManager).find(Member.class, memberId); + // OAuth2Member는 조회되지 않음 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(OAuth2Member.class, memberId); + } + + @Test + @DisplayName("loginType으로 OAuth2 회원 조회 성공") + void getMemberOrThrow_WithLoginType_OAuth2_Success() { + // given + Long memberId = 2L; + String loginType = "OAUTH2"; + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getLoginId()).isEqualTo("oauth@test.com"); + assertThat(result.getName()).isEqualTo("소셜사용자"); + assertThat(result.getAge()).isEqualTo(25); + assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); + + verify(entityManager).find(OAuth2Member.class, memberId); + // Member는 조회되지 않음 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(Member.class, memberId); + } + + @Test + @DisplayName("loginType이 LOCAL이지만 회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_WithLoginType_Local_NotFound() { + // given + Long memberId = 999L; + String loginType = "LOCAL"; + given(entityManager.find(Member.class, memberId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(entityManager).find(Member.class, memberId); + } + + @Test + @DisplayName("loginType이 OAUTH2이지만 회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_WithLoginType_OAuth2_NotFound() { + // given + Long memberId = 999L; + String loginType = "OAUTH2"; + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(entityManager).find(OAuth2Member.class, memberId); + } + + @Test + @DisplayName("loginType이 null일 때는 기본값 LOCAL로 처리") + void getMemberOrThrow_WithLoginType_Null_DefaultsToLocal() { + // given + Long memberId = 1L; + String loginType = null; + given(entityManager.find(Member.class, memberId)).willReturn(localMember); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + verify(entityManager).find(Member.class, memberId); + } +} diff --git a/infra/main.tf b/infra/main.tf index 8d9fb654..02a5197c 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -419,25 +419,30 @@ docker run -d \ -c 'ollama serve & sleep 5 && ollama pull daynice/kure-v1:567m && wait' echo "${var.github_access_token_1}" | docker login ghcr.io -u ${var.github_access_token_1_owner} --password-stdin -# zookeeper 설치 -docker run -d \ - --name zookeeper \ - --network common \ - -p 2181:2181 \ - -e ALLOW_ANONYMOUS_LOGIN=yes \ - bitnami/zookeeper:latest - -# kafka -docker run -d \ - --name kafka \ - --network common \ - -p 9092:9092 \ - -e KAFKA_BROKER_ID=1 \ - -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ - -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ - -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ - -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ - confluentinc/cp-kafka:7.6.0 +# # zookeeper 설치 (카프카용, 필요시 주석 해제) +# docker run -d \ +# --name zookeeper \ +# --restart unless-stopped \ +# --network common \ +# -p 2181:2181 \ +# -e ZOOKEEPER_CLIENT_PORT=2181 \ +# -e ZOOKEEPER_TICK_TIME=2000 \ +# confluentinc/cp-zookeeper:7.8.0 +# +# # kafka +# docker run -d \ +# --name kafka \ +# --restart unless-stopped \ +# --network common \ +# -p 9092:9092 \ +# -v kafka-data:/var/lib/kafka/data \ +# -e KAFKA_BROKER_ID=1 \ +# -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ +# -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ +# -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \ +# -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ +# -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ +# confluentinc/cp-kafka:7.8.0