diff --git a/backend/.env.default b/backend/.env.default index b4766537..687dd511 100644 --- a/backend/.env.default +++ b/backend/.env.default @@ -77,3 +77,5 @@ CUSTOM_CORS_ALLOWED_ORIGINS=NEED_TO_SET CUSTOM_OAUTH2_REDIRECT_URL=NEED_TO_SET CUSTOM_OAUTH2_FAILURE_URL=NEED_TO_SET CUSTOM_FRONTEND_URL=NEED_TO_SET +PROD_COOKIE_DOMAIN=NEED_TO_SET +DEV_COOKIE_DOMAIN=NEED_TO_SET diff --git a/backend/build.gradle b/backend/build.gradle index ee054210..008907e0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -41,6 +41,8 @@ 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 (문서화) implementation 'org.apache.commons:commons-lang3:3.18.0' @@ -60,8 +62,8 @@ dependencies { implementation 'org.springframework.session:spring-session-data-redis' // Logging & Monitoring (로깅/모니터링) - implementation 'io.sentry:sentry-spring-boot-starter:7.16.0' - implementation 'io.sentry:sentry-logback:7.16.0' + implementation 'io.sentry:sentry-spring-boot-starter:7.18.0' + implementation 'io.sentry:sentry-logback:7.18.0' // Development Tools (개발 도구) compileOnly 'org.projectlombok:lombok' diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 6726a097..73a04009 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -17,14 +17,14 @@ services: volumes: - mysql-data:/var/lib/mysql - ./sql:/sql # 호스트 ./sql 폴더를 컨테이너 /sql에 마운트 - command: > + command: > --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci --default-time-zone=Asia/Seoul --skip-log-bin --lower-case-table-names=1 healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${DEV_DATASOURCE_PASSWORD}"] + test: [ "CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${DEV_DATASOURCE_PASSWORD}" ] interval: 10s timeout: 5s retries: 10 @@ -40,7 +40,7 @@ services: command: > redis-server --appendonly yes healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 10 @@ -62,6 +62,10 @@ services: image: ollama/ollama:latest container_name: ollama restart: unless-stopped + deploy: + resources: + limits: + memory: 2g ports: - "11434:11434" volumes: @@ -78,8 +82,40 @@ 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: \ No newline at end of file + ollama-data: + kafka-data: \ No newline at end of file diff --git a/backend/sql/member_id_sequence.sql b/backend/sql/member_id_sequence.sql new file mode 100644 index 00000000..4b097ddb --- /dev/null +++ b/backend/sql/member_id_sequence.sql @@ -0,0 +1,17 @@ +-- ============================================ +-- 공유 member_id 시퀀스 테이블 생성 +-- ============================================ +-- 주의: 기존 배포 환경에 데이터가 있다면 +-- backend/sql/migrate_member_id_sequence.sql을 먼저 실행해야 합니다! +-- ============================================ + +CREATE TABLE IF NOT EXISTS member_id_sequence ( + sequence_name VARCHAR(255) NOT NULL PRIMARY KEY, + next_val BIGINT NOT NULL +); + +-- 초기값 설정: 신규 설치 시 1부터 시작 +-- 기존 환경은 마이그레이션 스크립트로 처리 +INSERT INTO member_id_sequence (sequence_name, next_val) +VALUES ('member_id_seq', 1) +ON DUPLICATE KEY UPDATE sequence_name = sequence_name; diff --git a/backend/sql/migrate_member_id_sequence.sql b/backend/sql/migrate_member_id_sequence.sql new file mode 100644 index 00000000..2916b1b2 --- /dev/null +++ b/backend/sql/migrate_member_id_sequence.sql @@ -0,0 +1,107 @@ +-- ============================================ +-- member_id 중복 해결 및 공유 시퀀스 설정 마이그레이션 +-- ============================================ +-- 실행 전 주의사항: +-- 1. 반드시 데이터베이스 백업을 먼저 수행하세요! +-- 2. 서비스를 중단한 상태에서 실행하세요 (데이터 정합성) +-- 3. 실행 후 애플리케이션을 재시작하세요 +-- ============================================ + +-- 1단계: 현재 상태 확인 +SELECT '=== 현재 member 테이블 ===' as info; +SELECT COUNT(*) as count, MIN(member_id) as min_id, MAX(member_id) as max_id FROM member; + +SELECT '=== 현재 oauth2_member 테이블 ===' as info; +SELECT COUNT(*) as count, MIN(member_id) as min_id, MAX(member_id) as max_id FROM oauth2_member; + +SELECT '=== 중복 확인 ===' as info; +SELECT m.member_id, 'BOTH' as status +FROM member m +INNER JOIN oauth2_member o ON m.member_id = o.member_id; + +-- 2단계: oauth2_member의 member_id를 재할당 +-- member 테이블의 최대값 이후부터 시작 +SET @max_member_id := (SELECT COALESCE(MAX(member_id), 0) FROM member); + +SELECT CONCAT('member 테이블 최대 ID: ', @max_member_id) as info; + +-- 임시 테이블로 매핑 생성 +CREATE TEMPORARY TABLE IF NOT EXISTS oauth2_member_id_mapping ( + old_member_id BIGINT, + new_member_id BIGINT, + PRIMARY KEY (old_member_id) +); + +-- 매핑 데이터 생성 +INSERT INTO oauth2_member_id_mapping (old_member_id, new_member_id) +SELECT + member_id as old_member_id, + @max_member_id + ROW_NUMBER() OVER (ORDER BY member_id) as new_member_id +FROM oauth2_member +ORDER BY member_id; + +SELECT '=== 매핑 테이블 ===' as info; +SELECT * FROM oauth2_member_id_mapping; + +-- 3단계: 외래키 제약 확인 (있다면 비활성화) +SET FOREIGN_KEY_CHECKS = 0; + +-- 4단계: 연관 테이블 업데이트 (member_id를 외래키로 가진 테이블만) +-- Post 테이블 (FK: member_id) +UPDATE post p +INNER JOIN oauth2_member_id_mapping m ON p.member_id = m.old_member_id +SET p.member_id = m.new_member_id; + +-- PollVote 테이블 (FK: member_id) +UPDATE poll_vote pv +INNER JOIN oauth2_member_id_mapping m ON pv.member_id = m.old_member_id +SET pv.member_id = m.new_member_id; + +-- History 테이블 (FK: member_id) +UPDATE history h +INNER JOIN oauth2_member_id_mapping m ON h.member_id = m.old_member_id +SET h.member_id = m.new_member_id; + +-- 5단계: oauth2_member 테이블 업데이트 +UPDATE oauth2_member o +INNER JOIN oauth2_member_id_mapping m ON o.member_id = m.old_member_id +SET o.member_id = m.new_member_id; + +-- 6단계: 외래키 제약 다시 활성화 +SET FOREIGN_KEY_CHECKS = 1; + +-- 7단계: 시퀀스 테이블 생성 +CREATE TABLE IF NOT EXISTS member_id_sequence ( + sequence_name VARCHAR(255) NOT NULL PRIMARY KEY, + next_val BIGINT NOT NULL +); + +-- 8단계: 초기값 설정 (재할당 후 최대값 + 1) +INSERT INTO member_id_sequence (sequence_name, next_val) +SELECT 'member_id_seq', COALESCE(MAX(max_id), 0) + 1 +FROM ( + SELECT COALESCE(MAX(member_id), 0) as max_id FROM member + UNION ALL + SELECT COALESCE(MAX(member_id), 0) as max_id FROM oauth2_member +) as max_values +ON DUPLICATE KEY UPDATE next_val = VALUES(next_val); + +-- 9단계: 최종 상태 확인 +SELECT '=== 마이그레이션 후 member 테이블 ===' as info; +SELECT COUNT(*) as count, MIN(member_id) as min_id, MAX(member_id) as max_id FROM member; + +SELECT '=== 마이그레이션 후 oauth2_member 테이블 ===' as info; +SELECT COUNT(*) as count, MIN(member_id) as min_id, MAX(member_id) as max_id FROM oauth2_member; + +SELECT '=== 중복 확인 (0건이어야 함) ===' as info; +SELECT m.member_id, 'BOTH' as status +FROM member m +INNER JOIN oauth2_member o ON m.member_id = o.member_id; + +SELECT '=== 시퀀스 초기값 ===' as info; +SELECT * FROM member_id_sequence; + +-- 임시 테이블 삭제 +DROP TEMPORARY TABLE IF EXISTS oauth2_member_id_mapping; + +SELECT '=== 마이그레이션 완료! ===' as info; \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/BackendApplication.java b/backend/src/main/java/com/ai/lawyer/BackendApplication.java index f35a28fd..89bfdf4a 100644 --- a/backend/src/main/java/com/ai/lawyer/BackendApplication.java +++ b/backend/src/main/java/com/ai/lawyer/BackendApplication.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication @EnableJpaAuditing @ConfigurationPropertiesScan diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java index a18190ed..557f528f 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/HistoryController.java @@ -2,7 +2,6 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; -import com.ai.lawyer.domain.chatbot.service.ChatService; import com.ai.lawyer.domain.chatbot.service.HistoryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -21,7 +20,6 @@ public class HistoryController { private final HistoryService historyService; - private final ChatService chatService; @Operation(summary = "채팅방 제목 목록 조회") @GetMapping("/") @@ -32,7 +30,7 @@ public ResponseEntity> getHistoryTitles(@AuthenticationPrincipa @Operation(summary = "채팅 조회") @GetMapping("/{historyId}") public ResponseEntity> getChatHistory(@AuthenticationPrincipal Long memberId, @PathVariable("historyId") Long roomId) { - return chatService.getChatHistory(memberId, roomId); + return historyService.getChatHistory(memberId, roomId); } @Operation(summary = "채팅방 삭제") diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java index 93095e2c..16c21043 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/dto/ChatDto.java @@ -34,9 +34,6 @@ public static class ChatResponse { @Schema(description = "채팅방 ID", example = "1") private Long roomId; - @Schema(description = "History 방 제목", example = "손해배상 청구 관련 문의") - private String title; - @Schema(description = "AI 챗봇의 응답 메시지", example = "네, 관련 법령과 판례를 바탕으로 답변해 드리겠습니다.") private String message; diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java index 5067209f..80765d9b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/Chat.java @@ -33,10 +33,10 @@ public class Chat { @Lob private String message; - @OneToMany(mappedBy = "chatId") + @OneToMany(mappedBy = "chatId", cascade = CascadeType.ALL, orphanRemoval = true) private List chatPrecedents; - @OneToMany(mappedBy = "chatId") + @OneToMany(mappedBy = "chatId", cascade = CascadeType.ALL, orphanRemoval = true) private List chatLaws; @CreationTimestamp diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java index 16eb6cc8..fb0f1a2e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java @@ -25,7 +25,7 @@ public class History { private Long historyId; @ManyToOne - @JoinColumn(name = "member_id") + @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "FK_HISTORY_MEMBER")) private Member memberId; @OneToMany(mappedBy = "historyId", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java index be1674a5..bea2a9a9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java @@ -2,8 +2,18 @@ import com.ai.lawyer.domain.chatbot.entity.ChatLaw; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ChatLawRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 ChatLaw 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java index a0e37661..820456d1 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java @@ -2,6 +2,16 @@ import com.ai.lawyer.domain.chatbot.entity.ChatPrecedent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ChatPrecedentRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 ChatPrecedent 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM ChatPrecedent cp WHERE cp.chatId.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java index 99d31525..08ca84db 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatRepository.java @@ -2,8 +2,18 @@ import com.ai.lawyer.domain.chatbot.entity.Chat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ChatRepository extends JpaRepository { + + /** + * member_id에 해당하는 모든 Chat 삭제 (회원 탈퇴 시 사용) + */ + @Modifying + @Query("DELETE FROM Chat c WHERE c.historyId.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java index 30828254..ec094d4b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java @@ -3,6 +3,9 @@ import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -14,4 +17,12 @@ public interface HistoryRepository extends JpaRepository { History findByHistoryIdAndMemberId(Long roomId, Member memberId); + /** + * member_id로 채팅 히스토리 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM History h WHERE h.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); + } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java new file mode 100644 index 00000000..df6efeff --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java @@ -0,0 +1,133 @@ +package com.ai.lawyer.domain.chatbot.service; + +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; +import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; +import com.ai.lawyer.domain.chatbot.entity.*; +import com.ai.lawyer.domain.chatbot.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.document.Document; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +// 더이상 사용 안함 +// 테스트 용도로 남겨둠 +@Slf4j +@Service +@RequiredArgsConstructor +public class AsyncPostChatProcessingService { + + private final KeywordService keywordService; + private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final KeywordRankRepository keywordRankRepository; + private final ChatMemoryRepository chatMemoryRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; + + @Value("${custom.ai.title-extraction}") + private String titleExtraction; + @Value("{$custom.ai.keyword-extraction}") + private String keywordExtraction; + + //@Async + @Transactional + public void processHandlerTasks(Long historyId, String userMessage, String fullResponse, List similarCaseDocuments, List similarLawDocuments) { + try { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다. historyId: " + historyId)); + + // 1. 메시지 기억 저장 + ChatMemory chatMemory = MessageWindowChatMemory.builder() + .maxMessages(10) + .chatMemoryRepository(chatMemoryRepository) + .build(); + + chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); + chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); + + // 2. 채팅방 제목 설정 / 및 필터 + setHistoryTitle(userMessage, history, fullResponse); + + // 3. 채팅 기록 저장 + saveChatWithDocuments(history, MessageType.USER, userMessage, similarCaseDocuments, similarLawDocuments); + saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); + + // 4. 키워드 추출 및 랭킹 업데이트 + if (!fullResponse.contains("해당 질문은 법률")) { + extractAndUpdateKeywordRanks(userMessage); + } + } catch (Exception e) { + log.error("에러 발생: {}", historyId, e); + } + } + + private void setHistoryTitle(String userMessage, History history, String fullResponse) { + String targetText = fullResponse.contains("해당 질문은 법률") ? userMessage : fullResponse; + TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); + history.setTitle(titleDto.getTitle()); + historyRepository.save(history); + } + + private void extractAndUpdateKeywordRanks(String message) { + KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); + if (keywordResponse == null || keywordResponse.getKeyword() == null) { + return; + } + + KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); + + if (keywordRank == null) { + keywordRank = KeywordRank.builder() + .keyword(keywordResponse.getKeyword()) + .score(1L) + .build(); + } else { + keywordRank.setScore(keywordRank.getScore() + 1); + } + keywordRankRepository.save(keywordRank); + } + + private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + Chat chat = chatRepository.save(Chat.builder() + .historyId(history) + .type(type) + .message(message) + .build()); + + // Ai 메시지가 저장될 때 관련 문서 저장 + if (type == MessageType.ASSISTANT) { + if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { + List chatPrecedents = similarCaseDocuments.stream() + .map(doc -> ChatPrecedent.builder() + .chatId(chat) + .precedentContent(doc.getText()) + .caseNumber(doc.getMetadata().get("caseNumber").toString()) + .caseName(doc.getMetadata().get("caseName").toString()) + .build()) + .collect(Collectors.toList()); + chatPrecedentRepository.saveAll(chatPrecedents); + } + + if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { + List chatLaws = similarLawDocuments.stream() + .map(doc -> ChatLaw.builder() + .chatId(chat) + .content(doc.getText()) + .lawName(doc.getMetadata().get("lawName").toString()) + .build()) + .collect(Collectors.toList()); + chatLawRepository.saveAll(chatLaws); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index a14af9eb..f1bb75ec 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -4,10 +4,10 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatPrecedentDto; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatRequest; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; -import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; -import com.ai.lawyer.domain.chatbot.entity.*; -import com.ai.lawyer.domain.chatbot.repository.*; +import com.ai.lawyer.domain.chatbot.entity.History; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; +import com.ai.lawyer.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; @@ -17,12 +17,16 @@ import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import java.util.HashMap; @@ -36,66 +40,79 @@ public class ChatBotService { private final ChatClient chatClient; - private final QdrantService qdrantService; private final HistoryService historyService; - private final KeywordService keywordService; - - private final ChatRepository chatRepository; + private final MemberRepository memberRepository; private final HistoryRepository historyRepository; - private final KeywordRankRepository keywordRankRepository; private final ChatMemoryRepository chatMemoryRepository; - private final MemberRepository memberRepository; - private final ChatPrecedentRepository chatPrecedentRepository; - private final ChatLawRepository chatLawRepository; + + // KafkaTemplate 주입 + private final KafkaTemplate kafkaTemplate; @Value("${custom.ai.system-message}") private String systemMessageTemplate; - @Value("${custom.ai.title-extraction}") - private String titleExtraction; - @Value("{$custom.ai.keyword-extraction}") - private String keywordExtraction; + + // Kafka 토픽 이름 -> 추후 application.yml로 이동 고려 + private static final String POST_PROCESSING_TOPIC = "chat-post-processing"; // 핵심 로직 - // 멤버 조회 -> 벡터 검색 (판례, 법령) -> 프롬프트 생성 (시스템, 유저) -> 채팅 클라이언트 호출 (스트림) -> 응답 저장, 제목/키워드 추출 - public Flux sendMessage(Long memberId, ChatRequest chatChatRequestDto, Long roomId) { + // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> Kafka 이벤트 발행 -> 응답 반환 + @Transactional + public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); // 벡터 검색 (판례, 법령) - List similarCaseDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "판례"); - List similarLawDocuments = qdrantService.searchDocument(chatChatRequestDto.getMessage(), "type", "법령"); + List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); + List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); - // 판례와 법령 정보를 구분 있게 포맷팅 String caseContext = formatting(similarCaseDocuments); String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 or 생성 -> 없으면 생성 + // 채팅방 조회 또는 생성 History history = getOrCreateRoom(member, roomId); - // 메시지 기억 관리 (최대 10개) - // 멀티턴 -> 10개까지 기억 이거 안하면 매번 처음부터 대화 (멍충한 AI) - ChatMemory chatMemory = saveChatMemory(chatChatRequestDto, history); + // 메시지 기억 관리 (User 메시지 추가) + ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); // 프롬프트 생성 Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // 복잡하긴 한데 이게 제일 깔끔한듯 + // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 return chatClient.prompt(prompt) .stream() .content() .collectList() .map(fullResponseList -> String.join("", fullResponseList)) - .doOnNext(fullResponse -> handlerTasks(chatChatRequestDto, history, fullResponse, chatMemory, similarCaseDocuments, similarLawDocuments)) // 응답이 완성되면 후처리 실행 (대화 저장, 키워드/제목 추출 등) - .map(fullResponse -> ChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments) // 최종적으로 ChatResponse DTO 생성 - ).flux() - .onErrorResume(throwable -> Flux.just(handleError(history))); // 에러 발생 시 에러 핸들링 -> 재전송 유도 + .doOnNext(fullResponse -> { + + // 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); + + }) + .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) + .flux() + .onErrorResume(throwable -> { + log.error("스트리밍 처리 중 에러 발생 (historyId: {})", history.getHistoryId(), throwable); + return Flux.just(handleError(history)); + }); } - private ChatResponse ChatResponse(History history, String fullResponse, List cases, List laws) { - + private ChatResponse createChatResponse(History history, String fullResponse, List cases, List laws) { ChatPrecedentDto precedentDto = null; if (cases != null && !cases.isEmpty()) { Document firstCase = cases.get(0); @@ -110,118 +127,32 @@ private ChatResponse ChatResponse(History history, String fullResponse, List ai 답변은 Consumer에서 추가 + chatMemory.add(String.valueOf(history.getHistoryId()), new UserMessage(chatRequestDto.getMessage())); return chatMemory; } private Prompt getPrompt(String caseContext, String lawContext, ChatMemory chatMemory, History history) { - Map promptContext = new HashMap<>(); promptContext.put("caseContext", caseContext); promptContext.put("lawContext", lawContext); - // 시스템 메시지와 사용자 메시지 생성 가공 PromptTemplate promptTemplate = new PromptTemplate(systemMessageTemplate); Message systemMessage = new SystemMessage(promptTemplate.create(promptContext).getContents()); UserMessage userMessage = new UserMessage(chatMemory.get(history.getHistoryId().toString()).toString()); - Prompt prompt = new Prompt(List.of(systemMessage, userMessage)); - - return prompt; - } - - private ChatResponse handleError(History history) { - return ChatResponse.builder() - .roomId(history.getHistoryId()) - .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") - .build(); - } - - private void handlerTasks(ChatRequest chatDto, History history, String fullResponse, ChatMemory chatMemory, List similarCaseDocuments, List similarLawDocuments) { - - // 메시지 기억 저장 - chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(fullResponse)); - chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); - - // 채팅방 제목 설정 / 및 필터 (법과 관련 없는 질문) - setHistoryTitle(chatDto, history, fullResponse); - - // 채팅 기록 저장 - saveChatWithDocuments(history, MessageType.USER, chatDto.getMessage(), similarCaseDocuments, similarLawDocuments); - saveChatWithDocuments(history, MessageType.ASSISTANT, fullResponse, similarCaseDocuments, similarLawDocuments); - - // 키워드 추출 및 키워드 랭킹 저장 (법과 관련 없는 질문은 제외) - if (!fullResponse.contains("해당 질문은 법률")) { - extractAndUpdateKeywordRanks(chatDto.getMessage()); - } - - } - - private void extractAndUpdateKeywordRanks(String message) { - KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); - - KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); - - if (keywordRank == null) { - keywordRank = KeywordRank.builder() - .keyword(keywordResponse.getKeyword()) - .score(1L) - .build(); - } else { - keywordRank.setScore(keywordRank.getScore() + 1); - } - - keywordRankRepository.save(keywordRank); - - } - - private void setHistoryTitle(ChatRequest chatDto, History history, String fullResponse) { - String targetText = fullResponse.contains("해당 질문은 법률") ? chatDto.getMessage() : fullResponse; - TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); - history.setTitle(titleDto.getTitle()); - historyRepository.save(history); - } - - private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { - Chat chat = chatRepository.save(Chat.builder() - .historyId(history) - .type(type) - .message(message) - .build()); - - if (type == MessageType.USER && similarCaseDocuments != null) { - List chatPrecedents = similarCaseDocuments.stream() - .map(doc -> ChatPrecedent.builder() - .chatId(chat) - .precedentContent(doc.getText()) - .caseNumber(doc.getMetadata().get("caseNumber").toString()) - .caseName(doc.getMetadata().get("caseName").toString()) - .build()) - .toList(); - chatPrecedentRepository.saveAll(chatPrecedents); - - List chatLaws = similarLawDocuments.stream() - .map(doc -> ChatLaw.builder() - .chatId(chat) - .content(doc.getText()) - .lawName(doc.getMetadata().get("lawName").toString()) - .build()) - .toList(); - - chatLawRepository.saveAll(chatLaws); - } + return new Prompt(List.of(systemMessage, userMessage)); } private History getOrCreateRoom(Member member, Long roomId) { @@ -232,11 +163,19 @@ private History getOrCreateRoom(Member member, Long roomId) { } } - private String formatting(List similarCaseDocuments) { - String context = similarCaseDocuments.stream() + private String formatting(List documents) { + if (documents == null || documents.isEmpty()) { + return ""; + } + return documents.stream() .map(Document::getFormattedContent) .collect(Collectors.joining("\n\n---\n\n")); - return context; } + private ChatResponse handleError(History history) { + return ChatResponse.builder() + .roomId(history.getHistoryId()) + .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") + .build(); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java deleted file mode 100644 index 22312409..00000000 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ai.lawyer.domain.chatbot.service; - -import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; -import com.ai.lawyer.domain.chatbot.entity.Chat; -import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ChatService { - - private final HistoryRepository historyRepository; - private final MemberRepository memberRepository; - - public ResponseEntity> getChatHistory(Long memberId, Long roomId) { - - Member member = memberRepository.findById(memberId).orElseThrow( - () -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); - - List chats = historyRepository.findByHistoryIdAndMemberId(roomId, member).getChats(); - List chatDtos = new ArrayList<>(); - - for (Chat chat : chats) { - ChatHistoryDto dto = ChatHistoryDto.from(chat); - chatDtos.add(dto); - } - - return ResponseEntity.ok(chatDtos); - - } - -} diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java index fb1da134..e1d9e1bb 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java @@ -1,13 +1,18 @@ package com.ai.lawyer.domain.chatbot.service; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; import com.ai.lawyer.domain.chatbot.dto.HistoryDto; +import com.ai.lawyer.domain.chatbot.entity.Chat; import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.exception.HistoryNotFoundException; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.infrastructure.redis.service.ChatCacheService; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; @@ -16,6 +21,8 @@ @RequiredArgsConstructor public class HistoryService { + private final ChatCacheService chatCacheService; + private final HistoryRepository historyRepository; private final MemberRepository memberRepository; @@ -45,10 +52,41 @@ public String deleteHistory(Long memberId, Long roomId) { History room = historyRepository.findByHistoryIdAndMemberId(roomId, member); historyRepository.delete(room); + chatCacheService.clearChatHistory(roomId); + return "채팅방이 삭제되었습니다."; } + @Transactional(readOnly = true) + public ResponseEntity> getChatHistory(Long memberId, Long roomId) { + + Member member = memberRepository.findById(memberId).orElseThrow( + () -> new IllegalArgumentException("존재하지 않는 회원입니다.") + ); + + // 1. Redis 캐시에서 조회 (있으면 바로 반환) + List cached = chatCacheService.getChatHistory(roomId); + if (!cached.isEmpty()) { + return ResponseEntity.ok(cached); + } + + // 2. DB에서 조회 후 캐시에 저장 + History history = historyRepository.findByHistoryIdAndMemberId(roomId, member); + List chats = history.getChats(); + + // 엔티티 -> DTO 변환 + List chatDtos = chats.stream() + .map(ChatHistoryDto::from) + .toList(); + + // DB 조회 결과를 Redis 캐시에 저장 + chatDtos.forEach(dto -> chatCacheService.cacheChatMessage(roomId, dto)); + + return ResponseEntity.ok(chatDtos); + + } + public History getHistory(Long roomId) { return historyRepository.findById(roomId).orElseThrow( () -> new HistoryNotFoundException(roomId) diff --git a/backend/src/main/java/com/ai/lawyer/domain/home/controller/HomeController.java b/backend/src/main/java/com/ai/lawyer/domain/home/controller/HomeController.java new file mode 100644 index 00000000..ad9b89d3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/home/controller/HomeController.java @@ -0,0 +1,29 @@ +package com.ai.lawyer.domain.home.controller; + +import com.ai.lawyer.domain.home.dto.FullData; +import com.ai.lawyer.domain.home.service.HomeService; +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/home") +@RequiredArgsConstructor +@Tag(name = "홈", description = "홈 화면 API") +public class HomeController { + + private final HomeService homeService; + + @Operation(summary = "데이터 수", description = "판례 법령 채팅 투표 수 조회") + @PostMapping("/data-count") + public ResponseEntity getDataCount() { + return ResponseEntity.ok(homeService.getDataCount()); + } + +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/home/dto/FullData.java b/backend/src/main/java/com/ai/lawyer/domain/home/dto/FullData.java new file mode 100644 index 00000000..d7ed37a1 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/home/dto/FullData.java @@ -0,0 +1,26 @@ +package com.ai.lawyer.domain.home.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "판례수 법령수 채팅수 투표수 DTO") +public class FullData { + + @Schema(description = "판례 수", example = "1000") + private Long precedentCount; + + @Schema(description = "법령 수", example = "500") + private Long lawCount; + + @Schema(description = "채팅 수", example = "2000") + private Long chatCount; + + @Schema(description = "투표 수", example = "300") + private Long voteCount; + +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/home/service/HomeService.java b/backend/src/main/java/com/ai/lawyer/domain/home/service/HomeService.java new file mode 100644 index 00000000..c31a4011 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/home/service/HomeService.java @@ -0,0 +1,39 @@ +package com.ai.lawyer.domain.home.service; + +import com.ai.lawyer.domain.chatbot.repository.ChatRepository; +import com.ai.lawyer.domain.home.dto.FullData; +import com.ai.lawyer.domain.law.repository.LawRepository; +import com.ai.lawyer.domain.poll.repository.PollRepository; +import com.ai.lawyer.domain.precedent.repository.PrecedentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HomeService { + + private final PrecedentRepository precedentRepository; + private final LawRepository lawRepository; + private final ChatRepository chatRepository; + private final PollRepository pollRepository; + + + public FullData getDataCount() { + + Long precedentCount = precedentRepository.count(); + Long lawCount = lawRepository.count(); + Long chatCount = chatRepository.count(); + Long voteCount = pollRepository.count(); + + return FullData.builder() + .precedentCount(precedentCount) + .lawCount(lawCount) + .chatCount(chatCount) + .voteCount(voteCount) + .build(); + + } + +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/controller/LawController.java b/backend/src/main/java/com/ai/lawyer/domain/law/controller/LawController.java index b6a86de0..ba189c3d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/controller/LawController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/controller/LawController.java @@ -8,10 +8,12 @@ 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.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor @Tag(name = "법령", description = "법령 API") @@ -39,24 +41,29 @@ public ResponseEntity getStatisticsCard( @PostMapping("/search") @Operation(summary = "볍령 목록 검색 기능", description = "조건에 맞는 법령 목록을 가져옵니다") - public ResponseEntity searchLaws(@RequestBody LawSearchRequestDto searchRequest) { - Page laws = lawService.searchLaws(searchRequest); - PageResponseDto response = PageResponseDto.builder() - .content(laws.getContent()) - .totalElements(laws.getTotalElements()) - .totalPages(laws.getTotalPages()) - .pageNumber(laws.getNumber()) - .pageSize(laws.getSize()) - .build(); - return ResponseEntity.ok(response); + public ResponseEntity searchLaws(@RequestBody LawSearchRequestDto searchRequest) { + try { + Page laws = lawService.searchLaws(searchRequest); + return ResponseEntity.ok(PageResponseDto.from(laws)); + }catch (Exception e){ + log.error("법령 목록 검색 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("법령 목록 검색 에러 : " + e.getMessage()); + } } @GetMapping("/{id}") @Operation(summary = "볍령 상세 조회 기능", description = "법령 상세 데이터를 조회합니다 \n" + "예시: /api/law/1") - public ResponseEntity getFullLaw(@PathVariable Long id) { - Law law = lawService.getLawWithAllChildren(id); + public ResponseEntity getFullLaw(@PathVariable Long id) { + try { + Law law = lawService.getLawWithAllChildren(id); + return ResponseEntity.ok(law); + }catch (Exception e){ + log.error("법령 상세 조회 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("법령 상세 조회 에러 : " + e.getMessage()); + } + + - return ResponseEntity.ok(law); } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/lawWord/controller/LawWordController.java b/backend/src/main/java/com/ai/lawyer/domain/lawWord/controller/LawWordController.java index 6d20c59d..b708949d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/lawWord/controller/LawWordController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/lawWord/controller/LawWordController.java @@ -5,12 +5,14 @@ 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.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/law-word") @@ -26,7 +28,8 @@ public ResponseEntity getPrecedentV1(@PathVariable String word) { try { return ResponseEntity.ok(lawWordService.findDefinition(word)); }catch (Exception e){ - return ResponseEntity.badRequest().body(e.getMessage()); + log.error("법령 용어 검색 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("법령 용어 검색 에러 : " + e.getMessage()); } } @@ -37,7 +40,8 @@ public ResponseEntity getPrecedentV2(@PathVariable String word) { try { return ResponseEntity.ok(lawWordService.findDefinitionV2(word)); }catch (Exception e){ - return ResponseEntity.badRequest().body(e.getMessage()); + log.error("법령 용어 검색 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("법령 용어 검색 에러 : " + e.getMessage()); } } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/lawWord/service/LawWordService.java b/backend/src/main/java/com/ai/lawyer/domain/lawWord/service/LawWordService.java index d07ea9e8..54e605f3 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/lawWord/service/LawWordService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/lawWord/service/LawWordService.java @@ -75,7 +75,7 @@ private String fetchAndSaveDefinition(String word) { private String fetchAndSaveDefinitionV2(String word) { try { - String url = buildKoreanDictApiUrl(word); + String url = buildApiUrlV2(word); // WebClient 호출 (동기 방식) String json = webClient.get() @@ -84,7 +84,7 @@ private String fetchAndSaveDefinitionV2(String word) { .bodyToMono(String.class) .block(); - String combinedDefinitions = extractTop3DefinitionsFromJson(json); + String combinedDefinitions = extractTop3DefinitionsFromJson(json, word); saveDefinition(word, combinedDefinitions); return combinedDefinitions; @@ -105,7 +105,7 @@ private String buildApiUrl(String word) { return API_BASE_URL + "?OC=" + API_OC + "&target=lstrm&type=JSON&query=" + word; } - private String buildKoreanDictApiUrl(String word) { + private String buildApiUrlV2(String word) { return UriComponentsBuilder.fromHttpUrl(KOREAN_DICT_API_BASE_URL) .queryParam("key", API_KEY) .queryParam("req_type", "json") @@ -114,9 +114,9 @@ private String buildKoreanDictApiUrl(String word) { .queryParam("sort", "dict") .queryParam("start", "1") .queryParam("num", "10") - .queryParam("advanced", "y") - .queryParam("type4", "all") - .queryParam("cat", "23") +// .queryParam("advanced", "y") +// .queryParam("type4", "all") +// .queryParam("cat", "23") .build() .toUriString(); } @@ -136,41 +136,77 @@ private String extractDefinitionFromJson(String json) throws JsonProcessingExcep } } - private String extractTop3DefinitionsFromJson(String json) throws JsonProcessingException { + private String extractTop3DefinitionsFromJson(String json, String requestedWord) throws JsonProcessingException { JsonNode rootNode = objectMapper.readTree(json); + JsonNode channelNode = rootNode.path("channel"); - // channel > item 배열에서 아이템들 추출 - JsonNode itemsNode = rootNode.path("channel").path("item"); + // 1. total이 0이면 '찾을수 없는 단어입니다' 리턴 + int total = channelNode.path("total").asInt(0); + if (total == 0) { + return "찾을수 없는 단어입니다"; + } + JsonNode itemsNode = channelNode.path("item"); if (!itemsNode.isArray() || itemsNode.size() == 0) { - throw new RuntimeException("검색 결과가 없습니다."); + return "찾을수 없는 단어입니다"; } - List definitions = new ArrayList<>(); - - // 최대 3개의 definition 추출 - for (int i = 0; i < Math.min(itemsNode.size(), 3); i++) { - JsonNode item = itemsNode.get(i); - JsonNode senseNode = item.path("sense"); - - if (senseNode.isArray() && senseNode.size() > 0) { - JsonNode firstSense = senseNode.get(0); - String definition = firstSense.path("definition").asText(); + // 2. 클라이언트가 요청한 단어와 정확히 일치하는 item만 필터링 + List matchingItems = new ArrayList<>(); + String normalizedRequestedWord = normalize(requestedWord); - if (definition != null && !definition.trim().isEmpty()) { - definitions.add(definition.trim()); - } + for (JsonNode item : itemsNode) { + String itemWord = item.path("word").asText(); + String normalizedItemWord = normalize(itemWord); + if (normalizedRequestedWord.equals(normalizedItemWord)) { + matchingItems.add(item); } } + if (matchingItems.isEmpty()) { + return "찾을수 없는 단어입니다"; + } + + // 3. 법률 카테고리 우선순위 적용 + List definitions = extractDefinitionsWithPriority(matchingItems); + if (definitions.isEmpty()) { - throw new RuntimeException("검색 결과에서 정의를 찾을 수 없습니다."); + return "찾을수 없는 단어입니다"; } - // 줄바꿈으로 연결하여 하나의 문자열로 만들기 + // 같은 word면 개수 제한 없이 모든 definition 반환 return String.join("\n", definitions); } + private List extractDefinitionsWithPriority(List matchingItems) { + List legalDefinitions = new ArrayList<>(); // 법률 카테고리 + List allDefinitions = new ArrayList<>(); // 모든 카테고리 + + for (JsonNode item : matchingItems) { + JsonNode senseNode = item.path("sense"); + + if (senseNode.isArray()) { + for (JsonNode sense : senseNode) { + String definition = sense.path("definition").asText(); + String cat = sense.path("cat").asText(""); + + if (definition != null && !definition.trim().isEmpty()) { + String cleanDefinition = definition.trim(); + allDefinitions.add(cleanDefinition); + + // cat이 "법률"인 경우 별도로 수집 + if ("법률".equals(cat)) { + legalDefinitions.add(cleanDefinition); + } + } + } + } + } + + // 법률 카테고리가 있으면 법률만, 없으면 모든 카테고리 반환 + return legalDefinitions.isEmpty() ? allDefinitions : legalDefinitions; + } + private void saveDefinition(String word, String definition) { LawWord entity = LawWord.builder() .word(word) @@ -178,4 +214,9 @@ private void saveDefinition(String word, String definition) { .build(); lawWordRepository.save(entity); } + + // 유틸: 단어 정규화 함수 + private String normalize(String input) { + return input.replaceAll("[\\s\\^]", ""); + } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index 54c113c1..8b241c3c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -23,7 +23,15 @@ public class Member implements MemberAdapter { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.TABLE, generator = "member_id_gen") + @TableGenerator( + name = "member_id_gen", + table = "member_id_sequence", + pkColumnName = "sequence_name", + valueColumnName = "next_val", + pkColumnValue = "member_id_seq", + allocationSize = 1 + ) @Column(name = "member_id", nullable = false) private Long memberId; diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberIdSequence.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberIdSequence.java new file mode 100644 index 00000000..20f59fe7 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberIdSequence.java @@ -0,0 +1,34 @@ +package com.ai.lawyer.domain.member.entity; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Member와 OAuth2Member가 공유하는 member_id 시퀀스 테이블 + * JPA의 @TableGenerator가 자동으로 관리하는 테이블을 엔티티로 명시 + */ +@Entity +@Table(name = "member_id_sequence") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString +public class MemberIdSequence { + + /** + * 시퀀스 이름 (Primary Key) + * Member와 OAuth2Member는 'member_id_seq' 값을 공유 + */ + @Id + @Column(name = "sequence_name", nullable = false) + private String sequenceName; + + /** + * 다음에 할당될 member_id 값 + * JPA의 @TableGenerator가 자동으로 증가시킴 + */ + @Column(name = "next_val", nullable = false) + private Long nextVal; +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java index 2217d3ae..c0df62cf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java @@ -24,7 +24,15 @@ public class OAuth2Member implements MemberAdapter { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.TABLE, generator = "member_id_gen") + @TableGenerator( + name = "member_id_gen", + table = "member_id_sequence", + pkColumnName = "sequence_name", + valueColumnName = "next_val", + pkColumnValue = "member_id_seq", + allocationSize = 1 + ) @Column(name = "member_id", nullable = false) private Long memberId; 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 8e02781a..b90f8525 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 @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -12,4 +13,6 @@ public interface MemberRepository extends JpaRepository { Optional findByLoginId(String loginId); boolean existsByLoginId(String loginId); + + List findByLoginIdIn(List loginIds); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 0806e9b5..2b2fc888 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -5,6 +5,12 @@ import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import com.ai.lawyer.domain.post.repository.PostRepository; +import com.ai.lawyer.domain.poll.repository.PollVoteRepository; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository; +import com.ai.lawyer.domain.chatbot.repository.ChatLawRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.email.service.EmailService; @@ -27,6 +33,12 @@ public class MemberService { private final CookieUtil cookieUtil; private final EmailService emailService; private final EmailAuthService emailAuthService; + private final PostRepository postRepository; + private final PollVoteRepository pollVoteRepository; + private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; public MemberService( MemberRepository memberRepository, @@ -34,13 +46,25 @@ public MemberService( TokenProvider tokenProvider, CookieUtil cookieUtil, EmailService emailService, - EmailAuthService emailAuthService) { + EmailAuthService emailAuthService, + PostRepository postRepository, + PollVoteRepository pollVoteRepository, + HistoryRepository historyRepository, + ChatRepository chatRepository, + ChatPrecedentRepository chatPrecedentRepository, + ChatLawRepository chatLawRepository) { this.memberRepository = memberRepository; this.passwordEncoder = passwordEncoder; this.tokenProvider = tokenProvider; this.cookieUtil = cookieUtil; this.emailService = emailService; this.emailAuthService = emailAuthService; + this.postRepository = postRepository; + this.pollVoteRepository = pollVoteRepository; + this.historyRepository = historyRepository; + this.chatRepository = chatRepository; + this.chatPrecedentRepository = chatPrecedentRepository; + this.chatLawRepository = chatLawRepository; } @org.springframework.beans.factory.annotation.Autowired(required = false) @@ -186,24 +210,76 @@ public String getLoginIdByMemberId(Long memberId) { @Transactional public void deleteMember(String loginId) { - // Member 또는 OAuth2Member 삭제 + log.info("회원 탈퇴 시작: loginId={}", loginId); + + // 1. Member 또는 OAuth2Member 조회하여 memberId 가져오기 + Long memberId = null; + boolean isRegularMember = false; + java.util.Optional regularMember = memberRepository.findByLoginId(loginId); if (regularMember.isPresent()) { - memberRepository.delete(regularMember.get()); - log.info("일반 회원 삭제 완료: loginId={}", loginId); + memberId = regularMember.get().getMemberId(); + isRegularMember = true; + log.info("일반 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } else if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + if (oauth2Member.isPresent()) { + memberId = oauth2Member.get().getMemberId(); + log.info("OAuth2 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } + } + + if (memberId == null) { + log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); return; } - if (oauth2MemberRepository != null) { + // 2. 연관된 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) + log.info("연관 데이터 삭제 시작: memberId={}", memberId); + + // 2-1. ChatPrecedent, ChatLaw 삭제 (Chat의 FK 참조) + chatPrecedentRepository.deleteByMemberIdValue(memberId); + log.info("채팅 판례 삭제 완료: memberId={}", memberId); + + chatLawRepository.deleteByMemberIdValue(memberId); + log.info("채팅 법령 삭제 완료: memberId={}", memberId); + + // 2-2. Chat 삭제 (History의 FK 참조) + chatRepository.deleteByMemberIdValue(memberId); + log.info("채팅 삭제 완료: memberId={}", memberId); + + // 2-3. History 삭제 (Member의 FK 참조) + historyRepository.deleteByMemberIdValue(memberId); + log.info("채팅 히스토리 삭제 완료: memberId={}", memberId); + + // 2-4. 투표 내역 삭제 + pollVoteRepository.deleteByMemberIdValue(memberId); + log.info("투표 내역 삭제 완료: memberId={}", memberId); + + // 2-5. 게시글 삭제 (Poll 엔티티도 cascade로 함께 삭제됨) + postRepository.deleteByMemberIdValue(memberId); + log.info("게시글 삭제 완료: memberId={}", memberId); + + // 3. Redis 토큰 삭제 + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); + + // 4. 회원 정보 삭제 + final Long finalMemberId = memberId; + if (isRegularMember) { + regularMember.ifPresent(member -> { + memberRepository.delete(member); + log.info("일반 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); + } else if (oauth2MemberRepository != null) { java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); - if (oauth2Member.isPresent()) { - oauth2MemberRepository.delete(oauth2Member.get()); - log.info("OAuth2 회원 삭제 완료: loginId={}", loginId); - return; - } + oauth2Member.ifPresent(member -> { + oauth2MemberRepository.delete(member); + log.info("OAuth2 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); } - log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); + log.info("회원 탈퇴 완료: loginId={}, memberId={}", loginId, finalMemberId); } public void sendCodeToEmailByLoginId(String loginId) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index c8d0e052..1bc4f83e 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 @@ -1,18 +1,17 @@ package com.ai.lawyer.domain.poll.controller; -import com.ai.lawyer.domain.poll.dto.PollCreateDto; -import com.ai.lawyer.domain.poll.dto.PollDto; -import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; -import com.ai.lawyer.domain.poll.dto.PollVoteDto; +import com.ai.lawyer.domain.poll.dto.*; import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollOptions; import com.ai.lawyer.domain.poll.service.PollService; import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.service.PostService; import com.ai.lawyer.global.response.ApiResponse; +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.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -26,6 +25,7 @@ @RestController @RequestMapping("/api/polls") @RequiredArgsConstructor +@Slf4j public class PollController { private final PollService pollService; @@ -34,17 +34,12 @@ public class PollController { @Operation(summary = "투표 단일 조회") @GetMapping("/{pollId}") public ResponseEntity> getPoll(@PathVariable Long pollId) { - PollDto poll = pollService.getPoll(pollId); + Long memberId = AuthUtil.getCurrentMemberId(); + log.info("PollController getPoll: memberId={}", memberId); + PollDto poll = pollService.getPoll(pollId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "투표 단일 조회 성공", poll)); } - @Operation(summary = "투표 옵션 목록 조회") - @GetMapping("/{pollId}/options") - public ResponseEntity>> getPollOptions(@PathVariable Long pollId) { - List options = pollService.getPollOptions(pollId); - return ResponseEntity.ok(new ApiResponse<>(200, "투표 옵션 목록 조회 성공", options)); - } - @Operation(summary = "투표하기") @PostMapping("/{pollId}/vote") public ResponseEntity> vote(@PathVariable Long pollId, @RequestParam Long pollItemsId) { @@ -68,24 +63,35 @@ public ResponseEntity> closePoll(@PathVariable Long pollId) { return ResponseEntity.ok(new ApiResponse<>(200, "투표가 종료되었습니다.", null)); } + @Operation(summary = "투표 수정") + @PutMapping("/{pollId}") + public ResponseEntity> updatePoll(@PathVariable Long pollId, @RequestBody PollUpdateDto pollUpdateDto) { + Long currentMemberId = AuthUtil.getCurrentMemberId(); + PollDto updated = pollService.updatePoll(pollId, pollUpdateDto, currentMemberId); + return ResponseEntity.ok(new ApiResponse<>(200, "투표가 수정되었습니다.", updated)); + } + @Operation(summary = "투표 삭제") @DeleteMapping("/{pollId}") public ResponseEntity> deletePoll(@PathVariable Long pollId) { - pollService.deletePoll(pollId); + Long currentMemberId = AuthUtil.getCurrentMemberId(); + pollService.deletePoll(pollId, currentMemberId); return ResponseEntity.ok(new ApiResponse<>(200, "투표가 삭제되었습니다.", null)); } @Operation(summary = "진행중인 투표 Top 1 조회") @GetMapping("/top/ongoing") public ResponseEntity> getTopOngoingPoll() { - PollDto poll = pollService.getTopPollByStatus(PollDto.PollStatus.ONGOING); + Long memberId = AuthUtil.getCurrentMemberId(); + PollDto poll = pollService.getTopPollByStatus(PollDto.PollStatus.ONGOING, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "진행중인 투표 Top 1 조회 성공", poll)); } @Operation(summary = "종료된 투표 Top 1 조회") @GetMapping("/top/closed") public ResponseEntity> getTopClosedPoll() { - PollDto poll = pollService.getTopPollByStatus(PollDto.PollStatus.CLOSED); + Long memberId = AuthUtil.getCurrentMemberId(); + PollDto poll = pollService.getTopPollByStatus(PollDto.PollStatus.CLOSED, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "종료된 투표 Top 1 조회 성공", poll)); } @@ -112,42 +118,37 @@ public ResponseEntity> createPoll(@RequestBody PollCreateDt return ResponseEntity.ok(new ApiResponse<>(201, "투표가 생성되었습니다.", created)); } - @Operation(summary = "투표 수정") - @PutMapping("/{pollId}") - public ResponseEntity> updatePoll(@PathVariable Long pollId, @RequestBody com.ai.lawyer.domain.poll.dto.PollUpdateDto pollUpdateDto) { - PollDto updated = pollService.updatePoll(pollId, pollUpdateDto); - return ResponseEntity.ok(new ApiResponse<>(200, "투표가 수정되었습니다.", updated)); - } - @Operation(summary = "진행중인 투표 전체 목록 조회") @GetMapping("/ongoing") public ResponseEntity>> getOngoingPolls() { - List polls = pollService.getPollsByStatus(PollDto.PollStatus.ONGOING); + Long memberId = AuthUtil.getCurrentMemberId(); + List polls = pollService.getPollsByStatus(PollDto.PollStatus.ONGOING, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "진행중인 투표 전체 목록 조회 성공", polls)); } @Operation(summary = "종료된 투표 전체 목록 조회") @GetMapping("/closed") public ResponseEntity>> getClosedPolls() { - List polls = pollService.getPollsByStatus(PollDto.PollStatus.CLOSED); + Long memberId = AuthUtil.getCurrentMemberId(); + List polls = pollService.getPollsByStatus(PollDto.PollStatus.CLOSED, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "종료된 투표 전체 목록 조회 성공", polls)); } - @Operation(summary = "종료된 투표 Top N 조회") - @GetMapping("/top/closed-list") //검색조건 : pi/polls/top/closed-list?size=3 - public ResponseEntity>> getTopClosedPolls(@RequestParam(defaultValue = "3") int size) { - List polls = pollService.getTopNPollsByStatus(PollDto.PollStatus.CLOSED, size); - String message = String.format("종료된 투표 Top %d 조회 성공", size); - return ResponseEntity.ok(new ApiResponse<>(200, message, polls)); - } - - @Operation(summary = "진행중인 투표 Top N 조회") - @GetMapping("/top/ongoing-list") //검색조건 : api/polls/top/ongoing-list?size=3 - public ResponseEntity>> getTopOngoingPolls(@RequestParam(defaultValue = "3") int size) { - List polls = pollService.getTopNPollsByStatus(PollDto.PollStatus.ONGOING, size); - String message = String.format("진행중인 투표 Top %d 조회 성공", size); - return ResponseEntity.ok(new ApiResponse<>(200, message, polls)); - } +// @Operation(summary = "종료된 투표 Top N 조회") +// @GetMapping("/top/closed-list") //검색조건 : pi/polls/top/closed-list?size=3 +// public ResponseEntity>> getTopClosedPolls(@RequestParam(defaultValue = "3") int size) { +// List polls = pollService.getTopNPollsByStatus(PollDto.PollStatus.CLOSED, size); +// String message = String.format("종료된 투표 Top %d 조회 성공", size); +// return ResponseEntity.ok(new ApiResponse<>(200, message, polls)); +// } +// +// @Operation(summary = "진행중인 투표 Top N 조회") +// @GetMapping("/top/ongoing-list") //검색조건 : api/polls/top/ongoing-list?size=3 +// public ResponseEntity>> getTopOngoingPolls(@RequestParam(defaultValue = "3") int size) { +// List polls = pollService.getTopNPollsByStatus(PollDto.PollStatus.ONGOING, size); +// String message = String.format("진행중인 투표 Top %d 조회 성공", size); +// return ResponseEntity.ok(new ApiResponse<>(200, message, polls)); +// } @Operation(summary = "index(순번)로 투표하기") @PostMapping("/{pollId}/voting") @@ -163,6 +164,15 @@ public ResponseEntity> voteByIndex(@PathVariable Long p return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } + @Operation(summary = "투표 취소하기") + @DeleteMapping("/vote") + public ResponseEntity> cancelVote(@RequestParam Long pollId) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Long memberId = Long.parseLong(authentication.getName()); + pollService.cancelVote(pollId, memberId); + 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/poll/dto/PollOptionDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollOptionDto.java index 12eb0240..669b2d10 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollOptionDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollOptionDto.java @@ -12,4 +12,5 @@ public class PollOptionDto { private Long voteCount; private java.util.List statics; private int pollOptionIndex; + private boolean voted; // 해당 옵션에 투표했는지 여부 } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java index a932eed3..e755ab8e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java @@ -12,4 +12,5 @@ public class PollStaticsResponseDto { private Long pollId; private List optionAgeStatics; private List optionGenderStatics; + private Long totalVoteCount; } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollTopDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollTopDto.java new file mode 100644 index 00000000..6cd9783e --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollTopDto.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.domain.poll.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Builder; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollTopDto { + private Long pollId; + private Long voteCount; +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollVoteDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollVoteDto.java index ece3dad8..3f3ffa3a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollVoteDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollVoteDto.java @@ -15,5 +15,5 @@ public class PollVoteDto { private Long pollItemsId; private Long memberId; private Long voteCount; + private String message; } - diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/Poll.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/Poll.java index e2a11d08..c03d8168 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/Poll.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/Poll.java @@ -32,6 +32,9 @@ public class Poll { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + @Column(name = "updated_at") + private LocalDateTime updatedAt; + @Column(name = "closed_at") private LocalDateTime closedAt; 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 8cd5b537..1f07ca49 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 @@ -1,8 +1,32 @@ package com.ai.lawyer.domain.poll.repository; import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; public interface PollVoteRepository extends JpaRepository, PollVoteRepositoryCustom { -} + Optional findByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); + void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); + List findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); + List findByMember_MemberId(Long memberId); + + /** + * member_id로 투표 내역 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); + boolean existsByPollAndMember(Poll poll, Member member); + + @Query("SELECT v.member.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/PollVoteRepositoryCustom.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java index 80834e8a..6fb5bf0a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java @@ -4,14 +4,17 @@ import com.ai.lawyer.domain.poll.entity.PollVote; import org.springframework.data.domain.Pageable; import java.util.List; +import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto; +import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto; +import com.ai.lawyer.domain.poll.dto.PollTopDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsDto; public interface PollVoteRepositoryCustom { - List findTopPollByStatus(Poll.PollStatus status); - List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable); + List findTopPollByStatus(Poll.PollStatus status); + List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable); Long countByPollId(Long pollId); Long countByPollOptionId(Long pollOptionId); - List countStaticsByPollOptionIds(List pollOptionIds); - List getOptionAgeStatics(Long pollId); - List getOptionGenderStatics(Long pollId); + List getOptionAgeStatics(Long pollId); + List getOptionGenderStatics(Long pollId); + List countStaticsByPollOptionIds(List pollOptionIds); } - 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 fc6d22a3..dfefe91f 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 @@ -1,5 +1,6 @@ package com.ai.lawyer.domain.poll.repository; +import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.poll.entity.Poll; import com.ai.lawyer.domain.poll.entity.QPoll; import com.ai.lawyer.domain.poll.entity.QPollOptions; @@ -13,6 +14,11 @@ import java.util.List; +import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto.AgeGroupCountDto; +import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto.GenderCountDto; +import com.ai.lawyer.domain.poll.dto.PollTopDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsDto; + @Repository @RequiredArgsConstructor public class PollVoteRepositoryImpl implements PollVoteRepositoryCustom { @@ -23,7 +29,7 @@ public class PollVoteRepositoryImpl implements PollVoteRepositoryCustom { private final QMember member = QMember.member; @Override - public List findTopPollByStatus(Poll.PollStatus status) { + public List findTopPollByStatus(Poll.PollStatus status) { List tuples = queryFactory.select(poll.getPollId(), pollVote.count()) .from(pollVote) .join(pollVote.getPoll(), poll) @@ -31,11 +37,16 @@ public List findTopPollByStatus(Poll.PollStatus status) { .groupBy(poll.getPollId()) .orderBy(pollVote.count().desc()) .fetch(); - return tuples.stream().map(Tuple::toArray).toList(); + return tuples.stream() + .map(t -> new PollTopDto( + t.get(0, Long.class), + t.get(1, Long.class) + )) + .toList(); } @Override - public List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable) { + public List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable) { List tuples = queryFactory.select(poll.getPollId(), pollVote.count()) .from(pollVote) .join(pollVote.getPoll(), poll) @@ -45,7 +56,12 @@ public List findTopNPollByStatus(Poll.PollStatus status, Pageable page .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); - return tuples.stream().map(Tuple::toArray).toList(); + return tuples.stream() + .map(t -> new PollTopDto( + t.get(0, Long.class), + t.get(1, Long.class) + )) + .toList(); } @Override @@ -67,19 +83,47 @@ public Long countByPollOptionId(Long pollOptionId) { } @Override - public List countStaticsByPollOptionIds(List pollOptionIds) { - List tuples = queryFactory.select(pollOptions.getPollItemsId(), member.getGender(), member.getAge(), pollVote.count()) + public List countStaticsByPollOptionIds(List pollOptionIds) { + List tuples = queryFactory.select( + pollOptions.getPollItemsId(), + member.getGender(), + member.getAge(), + pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) .join(pollVote.getMember(), member) .where(pollOptions.getPollItemsId().in(pollOptionIds)) .groupBy(pollOptions.getPollItemsId(), member.getGender(), member.getAge()) .fetch(); - return tuples.stream().map(Tuple::toArray).toList(); + 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(); + } + + private String getAgeGroup(Integer age) { + if (age == null) return "기타"; + if (age < 20) return "10대"; + if (age < 30) return "20대"; + if (age < 40) return "30대"; + if (age < 50) return "40대"; + if (age < 60) return "50대"; + if (age < 70) return "60대"; + if (age < 80) return "70대"; + return "80대 이상"; } @Override - public List getOptionAgeStatics(Long pollId) { + public List getOptionAgeStatics(Long pollId) { List tuples = queryFactory.select( pollOptions.getOption(), new com.querydsl.core.types.dsl.CaseBuilder() @@ -107,11 +151,17 @@ public List getOptionAgeStatics(Long pollId) { .when(member.getAge().lt(80)).then("70대") .otherwise("80대 이상")) .fetch(); - return tuples.stream().map(Tuple::toArray).toList(); + return tuples.stream() + .map(t -> new AgeGroupCountDto( + t.get(0, String.class), + t.get(1, String.class), + t.get(2, Long.class) + )) + .toList(); } @Override - public List getOptionGenderStatics(Long pollId) { + public List getOptionGenderStatics(Long pollId) { List tuples = queryFactory.select( pollOptions.getOption(), member.getGender(), @@ -122,6 +172,12 @@ public List getOptionGenderStatics(Long pollId) { .where(pollOptions.getPoll().getPollId().eq(pollId)) .groupBy(pollOptions.getOption(), member.getGender()) .fetch(); - return tuples.stream().map(Tuple::toArray).toList(); + return tuples.stream() + .map(t -> new GenderCountDto( + t.get(0, String.class), + t.get(1, Member.Gender.class).name(), + t.get(2, Long.class) + )) + .toList(); } } 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 95b230c4..63e7853a 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 @@ -14,12 +14,12 @@ public interface PollService { // ===== 조회 관련 ===== - PollDto getPoll(Long pollId); - PollDto getPollWithStatistics(Long pollId); + PollDto getPoll(Long pollId, Long memberId); + PollDto getPollWithStatistics(Long pollId, Long memberId); List getPollOptions(Long pollId); - List getPollsByStatus(PollDto.PollStatus status); - PollDto getTopPollByStatus(PollDto.PollStatus status); - List getTopNPollsByStatus(PollDto.PollStatus status, int n); + List getPollsByStatus(PollDto.PollStatus status, Long memberId); + PollDto getTopPollByStatus(PollDto.PollStatus status, Long memberId); + List getTopNPollsByStatus(PollDto.PollStatus status, int n, Long memberId); // ===== 통계 관련 ===== PollStaticsResponseDto getPollStatics(Long pollId); @@ -29,12 +29,15 @@ public interface PollService { // ===== 투표 관련 ===== PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + // ===== 투표 취소 관련 ===== + void cancelVote(Long pollId, Long memberId); + // ===== 생성/수정/삭제 관련 ===== PollDto createPoll(PollCreateDto request, Long memberId); - PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto); + PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId); void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); void closePoll(Long pollId); - void deletePoll(Long pollId); + void deletePoll(Long pollId, Long memberId); // ===== 검증 관련 ===== void validatePollCreate(PollCreateDto dto); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 8ffb40ca..fbc3488e 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 @@ -1,38 +1,32 @@ package com.ai.lawyer.domain.poll.service; +import com.ai.lawyer.domain.poll.dto.*; import com.ai.lawyer.domain.poll.entity.*; import com.ai.lawyer.domain.poll.repository.*; -import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.ArrayList; -import com.ai.lawyer.domain.poll.dto.PollCreateDto; -import com.ai.lawyer.domain.poll.dto.PollForPostDto; -import com.ai.lawyer.domain.poll.dto.PollOptionCreateDto; -import com.ai.lawyer.domain.poll.dto.PollStaticsDto; -import com.ai.lawyer.domain.poll.dto.PollOptionDto; -import com.ai.lawyer.domain.poll.dto.PollVoteDto; -import com.ai.lawyer.domain.poll.dto.PollUpdateDto; +import java.util.*; + import com.ai.lawyer.domain.poll.entity.Poll; import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; -import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto; -import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; -import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto; +import com.ai.lawyer.global.util.AuthUtil; @Service @Transactional +@Slf4j @RequiredArgsConstructor public class PollServiceImpl implements PollService { @@ -40,7 +34,6 @@ public class PollServiceImpl implements PollService { private final PollOptionsRepository pollOptionsRepository; private final PollVoteRepository pollVoteRepository; private final PollStaticsRepository pollStaticsRepository; - private final MemberRepository memberRepository; private final PostRepository postRepository; @Override @@ -49,8 +42,7 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID는 필수입니다."); } validatePollCommon(request.getVoteTitle(), request.getPollOptions(), request.getReservedCloseAt()); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = postRepository.findById(request.getPostId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); if (post.getPoll() != null) { @@ -63,6 +55,7 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { .voteTitle(request.getVoteTitle()) .status(Poll.PollStatus.ONGOING) .createdAt(now) + .updatedAt(now) .reservedCloseAt(request.getReservedCloseAt()) .build(); Poll savedPoll = pollRepository.save(poll); @@ -76,7 +69,7 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { .build(); pollOptionsRepository.save(option); } - return convertToDto(savedPoll); + return convertToDto(savedPoll, memberId, false); } catch (ResponseStatusException e) { throw e; } catch (Exception e) { @@ -85,18 +78,14 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { } @Override - public PollDto getPoll(Long pollId) { + public PollDto getPoll(Long pollId, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - autoClose(poll); - if (poll.getStatus() == Poll.PollStatus.CLOSED) { - return getPollWithStatistics(pollId); - } - return convertToDto(poll); + return convertToDto(poll, memberId, false); } @Override - public List getPollsByStatus(PollDto.PollStatus status) { + public List getPollsByStatus(PollDto.PollStatus status, Long memberId) { List polls = pollRepository.findAll(); for (Poll poll : polls) { autoClose(poll); @@ -104,8 +93,8 @@ public List getPollsByStatus(PollDto.PollStatus status) { List pollDtos = polls.stream() .filter(p -> p.getStatus().name().equals(status.name())) .map(p -> status == PollDto.PollStatus.CLOSED - ? getPollWithStatistics(p.getPollId()) - : convertToDto(p)) + ? getPollWithStatistics(p.getPollId(), memberId) + : getPoll(p.getPollId(), memberId)) .toList(); return pollDtos; } @@ -119,25 +108,43 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { } PollOptions pollOptions = pollOptionsRepository.findById(pollItemsId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표 항목을 찾을 수 없습니다.")); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); // USER 또는 ADMIN만 투표 가능 if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } - // 중복 투표 방지 - /* - if (pollVoteRepository.existsByPoll_PollIdAndMember_MemberId(pollId, memberId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 이 투표에 참여하셨습니다."); + // 기존 투표 내역 조회 + 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(); + } } - */ + // 기존 투표 내역이 없으면 정상 투표 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()) @@ -145,6 +152,7 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { .pollItemsId(pollItemsId) .memberId(memberId) .voteCount(voteCount) + .message("투표가 완료되었습니다.") .build(); } @@ -161,19 +169,12 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { PollOptions opt = options.get(i); optionMap.put(opt.getOption(), opt); } - // age 통계 그룹핑 - List optionAgeRaw = pollVoteRepository.getOptionAgeStatics(pollId); + List optionAgeRaw = pollVoteRepository.getOptionAgeStatics(pollId); java.util.Map> ageGroupMap = new java.util.HashMap<>(); - for (Object[] arr : optionAgeRaw) { - String option = arr[0] != null ? arr[0].toString() : null; - PollOptions opt = optionMap.get(option); + for (PollAgeStaticsDto.AgeGroupCountDto dto : optionAgeRaw) { + PollOptions opt = optionMap.get(dto.getOption()); if (opt == null) continue; Long pollItemsId = opt.getPollItemsId(); - PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder() - .option(option) - .ageGroup(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) - .build(); ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } java.util.List optionAgeStatics = new java.util.ArrayList<>(); @@ -185,19 +186,12 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { .ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) .build()); } - // gender 통계 그룹핑 - List optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId); + List optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId); java.util.Map> genderGroupMap = new java.util.HashMap<>(); - for (Object[] arr : optionGenderRaw) { - String option = arr[0] != null ? arr[0].toString() : null; - PollOptions opt = optionMap.get(option); + for (PollGenderStaticsDto.GenderCountDto dto : optionGenderRaw) { + PollOptions opt = optionMap.get(dto.getOption()); if (opt == null) continue; Long pollItemsId = opt.getPollItemsId(); - PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder() - .option(option) - .gender(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) - .build(); genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } java.util.List optionGenderStatics = new java.util.ArrayList<>(); @@ -209,15 +203,17 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) .build()); } + Long totalVoteCount = pollVoteRepository.countByPollId(pollId); return PollStaticsResponseDto.builder() .postId(postId) .pollId(pollId) .optionAgeStatics(optionAgeStatics) .optionGenderStatics(optionGenderStatics) + .totalVoteCount(totalVoteCount) .build(); } - // 최대 7일 동안 투표 가능 (초기 요구사항) + // 최대 7일 동안 투표 가능 @Override public void closePoll(Long pollId) { Poll poll = pollRepository.findById(pollId) @@ -228,10 +224,12 @@ public void closePoll(Long pollId) { } @Override - public void deletePoll(Long pollId) { + public void deletePoll(Long pollId, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - + if (poll.getPost() == null || !poll.getPost().getMember().getMemberId().equals(memberId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 삭제할 수 있습니다."); + } // 1. 이 Poll을 참조하는 Post가 있으면 연결 해제 Post post = postRepository.findAll().stream() .filter(p -> p.getPoll() != null && p.getPoll().getPollId().equals(pollId)) @@ -241,14 +239,13 @@ public void deletePoll(Long pollId) { post.setPoll(null); postRepository.save(post); } - // 2. Poll 삭제 pollRepository.deleteById(pollId); } @Override - public PollDto getTopPollByStatus(PollDto.PollStatus status) { - List result = pollVoteRepository.findTopPollByStatus(Poll.PollStatus.valueOf(status.name())); + public PollDto getTopPollByStatus(PollDto.PollStatus status, Long memberId) { + List result = pollVoteRepository.findTopPollByStatus(Poll.PollStatus.valueOf(status.name())); if (result.isEmpty()) { // 종료된 투표가 없으면 빈 PollDto 반환 return PollDto.builder() @@ -262,21 +259,21 @@ public PollDto getTopPollByStatus(PollDto.PollStatus status) { .totalVoteCount(0L) .build(); } - Long pollId = (Long) result.get(0)[0]; - return getPoll(pollId); + Long pollId = result.get(0).getPollId(); + return getPoll(pollId, memberId); } @Override - public List getTopNPollsByStatus(PollDto.PollStatus status, int n) { + public List getTopNPollsByStatus(PollDto.PollStatus status, int n, Long memberId) { Pageable pageable = org.springframework.data.domain.PageRequest.of(0, n); - List result = pollVoteRepository.findTopNPollByStatus( + List result = pollVoteRepository.findTopNPollByStatus( com.ai.lawyer.domain.poll.entity.Poll.PollStatus.valueOf(status.name()), pageable); List pollDtos = new java.util.ArrayList<>(); - for (Object[] row : result) { - Long pollId = (Long) row[0]; + for (PollTopDto row : result) { + Long pollId = row.getPollId(); pollDtos.add(status == PollDto.PollStatus.CLOSED - ? getPollWithStatistics(pollId) - : getPoll(pollId)); + ? getPollWithStatistics(pollId, memberId) + : getPoll(pollId, memberId)); } return pollDtos; } @@ -297,9 +294,12 @@ public Long getVoteCountByPostId(Long postId) { @Override - public PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto) { + 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)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 수정할 수 있습니다."); + } if (getVoteCountByPollId(pollId) > 0) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표가 진행된 투표는 수정할 수 없습니다."); } @@ -350,10 +350,10 @@ public PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); } poll.setReservedCloseAt(reservedCloseAt); - System.out.println("poll에 저장된 reservedCloseAt 값: " + poll.getReservedCloseAt()); } - Poll updated = pollRepository.save(poll); - return convertToDto(updated); + poll.setUpdatedAt(now); // 투표(Poll) 수정 시 updatedAt 갱신 + pollRepository.save(poll); + return getPoll(pollId, memberId); } @Override @@ -408,85 +408,40 @@ public void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); } poll.setReservedCloseAt(reservedCloseAt); - System.out.println("poll에 저장된 reservedCloseAt 값: " + poll.getReservedCloseAt()); } pollRepository.save(poll); } @Override - public PollDto getPollWithStatistics(Long pollId) { + public PollDto getPollWithStatistics(Long pollId, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - List options = pollOptionsRepository.findByPoll_PollId(poll.getPollId()); - List optionIds = options.stream().map(PollOptions::getPollItemsId).toList(); - Long totalVoteCount = pollVoteRepository.countByPollId(poll.getPollId()); - List optionDtos; - if (poll.getStatus() == Poll.PollStatus.CLOSED && !optionIds.isEmpty()) { - List staticsRaw = pollVoteRepository.countStaticsByPollOptionIds(optionIds); - optionDtos = new ArrayList<>(); - for (int i = 0; i < options.size(); i++) { - PollOptions option = options.get(i); - Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); - List statics = staticsRaw.stream() - .filter(arr -> ((Long)arr[0]).equals(option.getPollItemsId())) - .map(arr -> { - String gender = arr[1] != null ? arr[1].toString() : null; - Integer age = arr[2] != null ? ((Number)arr[2]).intValue() : null; - String ageGroup = getAgeGroup(age); - return PollStaticsDto.builder() - .gender(gender) - .ageGroup(ageGroup) - .voteCount((Long)arr[3]) - .build(); - }) - .toList(); - optionDtos.add(PollOptionDto.builder() - .pollItemsId(option.getPollItemsId()) - .content(option.getOption()) - .voteCount(voteCount) - .statics(statics) - .pollOptionIndex(i + 1) - .build()); - } - } else { - optionDtos = new ArrayList<>(); - for (int i = 0; i < options.size(); i++) { - PollOptions option = options.get(i); - Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); - optionDtos.add(PollOptionDto.builder() - .pollItemsId(option.getPollItemsId()) - .content(option.getOption()) - .voteCount(voteCount) - .statics(null) - .pollOptionIndex(i + 1) - .build()); - } - } - return PollDto.builder() - .pollId(poll.getPollId()) - .postId(poll.getPost() != null ? poll.getPost().getPostId() : null) - .voteTitle(poll.getVoteTitle()) - .status(PollDto.PollStatus.valueOf(poll.getStatus().name())) - .createdAt(poll.getCreatedAt()) - .closedAt(poll.getClosedAt()) - .pollOptions(optionDtos) - .totalVoteCount(totalVoteCount) - .build(); + return convertToDto(poll, memberId, true); } - private PollDto convertToDto(Poll poll) { + private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { List options = pollOptionsRepository.findByPoll_PollId(poll.getPollId()); List optionDtos = new ArrayList<>(); Long totalVoteCount = pollVoteRepository.countByPollId(poll.getPollId()); for (int i = 0; i < options.size(); i++) { PollOptions option = options.get(i); Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); + boolean voted = false; + if (memberId != null) { + voted = !pollVoteRepository.findByMember_MemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); + } + List statics = null; + if (withStatistics && poll.getStatus() == Poll.PollStatus.CLOSED) { + List staticsRaw = pollVoteRepository.countStaticsByPollOptionIds(List.of(option.getPollItemsId())); + statics = staticsRaw; + } optionDtos.add(PollOptionDto.builder() .pollItemsId(option.getPollItemsId()) .content(option.getOption()) .voteCount(voteCount) - .statics(null) + .statics(statics) .pollOptionIndex(i + 1) + .voted(voted) .build()); } LocalDateTime expectedCloseAt = poll.getReservedCloseAt() != null ? poll.getReservedCloseAt() : poll.getCreatedAt().plusDays(7); @@ -568,4 +523,10 @@ public void validatePollCreate(PollForPostDto dto) { public void validatePollCreate(PollCreateDto dto) { validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); } -} \ No newline at end of file + + @Override + public void cancelVote(Long pollId, Long memberId) { + pollVoteRepository.findByMember_MemberIdAndPoll_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 5078b888..38e00a6b 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 @@ -1,10 +1,13 @@ package com.ai.lawyer.domain.post.controller; +import com.ai.lawyer.domain.poll.dto.PollDto; +import com.ai.lawyer.domain.poll.dto.PollDto.PollStatus; import com.ai.lawyer.domain.post.dto.*; import com.ai.lawyer.domain.post.service.PostService; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.response.ApiResponse; +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; @@ -20,6 +23,9 @@ import java.util.List; +import static com.ai.lawyer.domain.poll.entity.Poll.PollStatus.CLOSED; +import static com.ai.lawyer.domain.poll.entity.Poll.PollStatus.ONGOING; + @Tag(name = "Post API", description = "게시글 관련 API") @RestController @RequestMapping("/api/posts") @@ -33,30 +39,22 @@ public class PostController { @Operation(summary = "게시글 등록") @PostMapping public ResponseEntity> createPost(@RequestBody PostRequestDto postRequestDto) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDto created = postService.createPost(postRequestDto, memberId); return ResponseEntity.ok(new ApiResponse<>(201, "게시글이 등록되었습니다.", created)); } - @PostMapping("/postdev") - public ResponseEntity> createPostDev(@RequestBody PostRequestDto postRequestDto, @RequestParam Long memberId) { - PostDto created = postService.createPost(postRequestDto, memberId); - return ResponseEntity.ok(new ApiResponse<>(201, "[DEV] 게시글이 등록되었습니다.", created)); - } +// @PostMapping("/postdev") +// public ResponseEntity> createPostDev(@RequestBody PostRequestDto postRequestDto, @RequestParam Long memberId) { +// PostDto created = postService.createPost(postRequestDto, memberId); +// return ResponseEntity.ok(new ApiResponse<>(201, "[DEV] 게시글이 등록되었습니다.", created)); +// } @Operation(summary = "게시글 전체 조회") @GetMapping("") public ResponseEntity>> getAllPosts() { - List posts = postService.getAllPosts(); + Long memberId = AuthUtil.getCurrentMemberId(); + List posts = postService.getAllPosts(memberId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글 전체 조회 성공", posts)); } @@ -70,7 +68,8 @@ public ResponseEntity>> getAllSimplePosts() { @Operation(summary = "게시글 단일 조회") @GetMapping("/{postId}") public ResponseEntity> getPostById(@PathVariable Long postId) { - PostDetailDto postDto = postService.getPostById(postId); + Long memberId = AuthUtil.getCurrentMemberId(); + PostDetailDto postDto = postService.getPostDetailById(postId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글 단일 조회 성공", postDto)); } @@ -78,7 +77,7 @@ public ResponseEntity> getPostById(@PathVariable Long @GetMapping("/member/{memberId}") public ResponseEntity>> getPostsByMember(@PathVariable Long memberId) { List posts = postService.getPostsByMemberId(memberId).stream() - .map(postDto -> postService.getPostDetailById(postDto.getPostId())) + .map(postDto -> postService.getPostDetailById(postDto.getPostId(), AuthUtil.getCurrentMemberId())) .toList(); return ResponseEntity.ok(new ApiResponse<>(200, "회원별 게시글 목록 조회 성공", posts)); } @@ -86,22 +85,31 @@ public ResponseEntity>> getPostsByMember(@PathVa @Operation(summary = "게시글 수정") @PutMapping("/{postId}") public ResponseEntity> updatePost(@PathVariable Long postId, @RequestBody PostUpdateDto postUpdateDto) { + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); + Long postOwnerId = postDetail.getPost().getMemberId(); + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.updatePost(postId, postUpdateDto); - PostDetailDto updated = postService.getPostDetailById(postId); + PostDetailDto updated = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 수정되었습니다.", updated)); } @Operation(summary = "게시글 부분 수정(PATCH)") @PatchMapping("/{postId}") public ResponseEntity> patchUpdatePost(@PathVariable Long postId, @RequestBody PostUpdateDto postUpdateDto) { + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); + Long postOwnerId = postDetail.getPost().getMemberId(); + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.patchUpdatePost(postId, postUpdateDto); - PostDetailDto updated = postService.getPostDetailById(postId); + PostDetailDto updated = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 수정되었습니다.", updated)); } @Operation(summary = "게시글 삭제") @DeleteMapping("/{postId}") public ResponseEntity> deletePost(@PathVariable Long postId) { + PostDetailDto postDetail = postService.getPostDetailById(postId, AuthUtil.getAuthenticatedMemberId()); + Long postOwnerId = postDetail.getPost().getMemberId(); + AuthUtil.validateOwnerOrAdmin(postOwnerId); postService.deletePost(postId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); } @@ -116,50 +124,35 @@ public ResponseEntity> handleResponseStatusException(ResponseS @Operation(summary = "본인 게시글 단일 조회") @GetMapping("/my/{postId}") public ResponseEntity> getMyPostById(@PathVariable Long postId) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDto postDto = postService.getMyPostById(postId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 단일 조회 성공", postDto)); } - @Operation(summary = "본인 게시글 전체 조회") - @GetMapping("/my") - public ResponseEntity>> getMyPosts() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); - } - List posts = postService.getMyPosts(memberId); - return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", posts)); - } + @Operation(summary = "본인 게시글 전체 조회") + @GetMapping("/my") + public ResponseEntity>> getMyPosts() { + Long memberId = AuthUtil.getAuthenticatedMemberId(); + List posts = postService.getMyPosts(memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", posts)); + } + + @Operation(summary = "본인 게시글 전체 패이징 조회") + @GetMapping("/mypaged") + public ResponseEntity>> getMyPosts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, org.springframework.data.domain.Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page response = postService.getMyPostspaged(pageable, memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", response)); + } @Operation(summary = "게시글+투표 동시 등록") @PostMapping("/createPost") public ResponseEntity> createPostWithPoll(@RequestBody PostWithPollCreateDto dto) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - Long memberId; - if (principal instanceof org.springframework.security.core.userdetails.User user) { - memberId = Long.valueOf(user.getUsername()); - } else if (principal instanceof Long) { - memberId = (Long) principal; - } else { - throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다."); - } + Long memberId = AuthUtil.getAuthenticatedMemberId(); PostDetailDto result = postService.createPostWithPoll(dto, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글+투표 등록 완료", result)); } @@ -170,12 +163,10 @@ public ResponseEntity> getPostsPaged( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size ) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - Page posts = postService.getPostsPaged(pageable); - if (posts == null) { - posts = new org.springframework.data.domain.PageImpl<>(java.util.Collections.emptyList(), pageable, 0); - } - PostPageDto response = new PostPageDto(posts); + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getCurrentMemberId(); + Page pageResult = postService.getPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); return ResponseEntity.ok(new ApiResponse<>(200, "페이징 게시글 조회 성공", response)); } @@ -185,12 +176,10 @@ public ResponseEntity> getOngoingPostsPaged( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size ) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - Page posts = postService.getOngoingPostsPaged(pageable); - if (posts == null) { - posts = new org.springframework.data.domain.PageImpl<>(java.util.Collections.emptyList(), pageable, 0); - } - PostPageDto response = new PostPageDto(posts); + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getCurrentMemberId(); + Page pageResult = postService.getOngoingPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); return ResponseEntity.ok(new ApiResponse<>(200, "진행중 투표 게시글 페이징 조회 성공", response)); } @@ -200,12 +189,87 @@ public ResponseEntity> getClosedPostsPaged( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size ) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - Page posts = postService.getClosedPostsPaged(pageable); - if (posts == null) { - posts = new org.springframework.data.domain.PageImpl<>(java.util.Collections.emptyList(), pageable, 0); - } - PostPageDto response = new PostPageDto(posts); + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getCurrentMemberId(); + Page pageResult = postService.getClosedPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); return ResponseEntity.ok(new ApiResponse<>(200, "마감된 투표 게시글 페이징 조회 성공", response)); } -} \ No newline at end of file + + @Operation(summary = "진행중인 투표 Top N 조회") + @GetMapping("/top/ongoingList") + public ResponseEntity>> getTopNOngoingPolls(@RequestParam(defaultValue = "3") int size) { + Long memberId = AuthUtil.getCurrentMemberId(); + List posts = postService.getTopNPollsByStatus( + PollStatus.valueOf(ONGOING.name()), size, memberId); + String message = String.format("진행중인 투표 Top %d 조회 성공", size); + return ResponseEntity.ok(new ApiResponse<>(200, message, posts)); + } + + @Operation(summary = "마감된 투표 Top N 조회") + @GetMapping("/top/closedList") + public ResponseEntity>> getTopNClosedPolls(@RequestParam(defaultValue = "3") int size) { + Long memberId = AuthUtil.getCurrentMemberId(); + List posts = postService.getTopNPollsByStatus( + PollStatus.valueOf(CLOSED.name()), size, memberId); + String message = String.format("종료된 투표 Top %d 조회 성공", size); + return ResponseEntity.ok(new ApiResponse<>(200, message, posts)); + } + + @Operation(summary = "진행중인 투표 Top 1 조회") + @GetMapping("/top/ongoing") + public ResponseEntity> getTopOngoingPoll() { + Long memberId = AuthUtil.getCurrentMemberId(); + PostDto post = postService.getTopPollByStatus( + PollStatus.valueOf(ONGOING.name()), memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "진행중인 투표 Top 1 조회 성공", post)); + } + + @Operation(summary = "마감된 투표 Top 1 조회") + @GetMapping("/top/closed") + public ResponseEntity> getTopClosedPoll() { + Long memberId = AuthUtil.getCurrentMemberId(); + PostDto post = postService.getTopPollByStatus( + PollStatus.valueOf(CLOSED.name()), memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "마감된 투표 Top 1 조회 성공", post)); + } + + @Operation(summary = "내가 참여한 진행중 투표 게시글 페이징 조회") + @GetMapping("/my/ongoingPaged") + public ResponseEntity> getMyOngoingPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page pageResult = postService.getMyOngoingPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 진행중 투표 게시글 페이징 조회 성공", response)); + } + + @Operation(summary = "내가 참여한 마감 투표 게시글 페이징 조회") + @GetMapping("/my/closedPaged") + public ResponseEntity> getMyClosedPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page pageResult = postService.getMyClosedPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 마감 투표 게시글 페이징 조회 성공", response)); + } + + @Operation(summary = "내가 참여한 모든 투표 게시글 페이징 조회") + @GetMapping("/my/votedPaged") + public ResponseEntity> getMyVotedPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("updatedAt").descending()); + Long memberId = AuthUtil.getAuthenticatedMemberId(); + Page pageResult = postService.getMyVotedPostsPaged(pageable, memberId); + PostPageDto response = new PostPageDto(pageResult); + return ResponseEntity.ok(new ApiResponse<>(200, "내가 참여한 모든 투표 게시글 페이징 조회 성공", response)); + } +} 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 new file mode 100644 index 00000000..170c4855 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java @@ -0,0 +1,41 @@ +package com.ai.lawyer.domain.post.controller; + +import com.ai.lawyer.domain.post.service.PostDummyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +@Tag(name = "더미생성") +@RestController +@RequestMapping("/api/dummy") +public class PostDummyController { + private final PostDummyService dummyService; + + @Autowired + public PostDummyController(PostDummyService dummyService) { + this.dummyService = dummyService; + } + + @Operation(summary = "더미 멤버 추가") + @PostMapping("/members") + public ResponseEntity createDummyMembers(@RequestParam(defaultValue = "100") int count) { + int created = dummyService.createDummyMembers(count); + return ResponseEntity.ok("더미 멤버 " + created + "명 생성 완료"); + } + + @Operation(summary = "더미 멤버 투표") + @PostMapping("/vote") + public ResponseEntity dummyVote(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } + + @Operation(summary = "더미 멤버 삭제") + @DeleteMapping("/members") + public ResponseEntity deleteDummyMembers() { + int deleted = dummyService.deleteDummyMembers(); + return ResponseEntity.ok("더미 멤버 " + deleted + "명 삭제 완료"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostDto.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostDto.java index d72b762b..f9925574 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostDto.java @@ -9,7 +9,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) -public class PostDto { +public class PostDto { private Long postId; private Long memberId; @@ -17,5 +17,6 @@ public class PostDto { private String postContent; private String category; private LocalDateTime createdAt; + private LocalDateTime updatedAt; private PollDto poll; } \ 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 cf30c5d7..74b2f23a 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 @@ -37,6 +37,9 @@ public class Post { @Column(name = "created_at") private LocalDateTime createdAt; + @Column(name = "updated_at") + private LocalDateTime updatedAt; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "poll_id", foreignKey = @ForeignKey(name = "FK_POST_POLL")) private Poll poll; diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index a69ff05f..db085a25 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 @@ -2,7 +2,13 @@ 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; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +16,19 @@ @Repository public interface PostRepository extends JpaRepository { List findByMember(Member member); + + /** + * member_id로 게시글 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM Post p WHERE p.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); + + Page findByMember(Member member, 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); + boolean existsByPostName(String postName); + List findByPostName(String postName); } \ No newline at end of file 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 new file mode 100644 index 00000000..c0348ec5 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java @@ -0,0 +1,131 @@ +package com.ai.lawyer.domain.post.service; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.Member.Gender; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.poll.entity.PollOptions; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.poll.repository.PollOptionsRepository; +import com.ai.lawyer.domain.poll.repository.PollVoteRepository; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.post.repository.PostRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +public class PostDummyService { + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final PollOptionsRepository pollOptionsRepository; + private final PollVoteRepository pollVoteRepository; + + @Autowired + public PostDummyService(MemberRepository memberRepository, + PostRepository postRepository, + PollOptionsRepository pollOptionsRepository, + PollVoteRepository pollVoteRepository) { + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.pollOptionsRepository = pollOptionsRepository; + this.pollVoteRepository = pollVoteRepository; + } + + @Transactional + public int createDummyMembers(int count) { + List allMembers = memberRepository.findAll(); + int maxDummyNumber = allMembers.stream() + .map(Member::getLoginId) + .filter(id -> id.startsWith("dummy") && id.endsWith("@test.com")) + .map(id -> { + try { + String numStr = id.substring(5, id.indexOf("@")); + return Integer.parseInt(numStr); + } catch (Exception e) { + return 0; + } + }) + .max(Integer::compareTo) + .orElse(0); + int start = maxDummyNumber + 1; + int end = start + count - 1; + List newLoginIds = new ArrayList<>(); + for (int i = start; i <= end; i++) { + newLoginIds.add("dummy" + i + "@test.com"); + } + List existingMembers = memberRepository.findByLoginIdIn(newLoginIds); + Set existingLoginIds = new HashSet<>(); + for (Member m : existingMembers) { + existingLoginIds.add(m.getLoginId()); + } + List membersToSave = new ArrayList<>(); + Random random = new Random(); + for (int i = start; i <= end; i++) { + String loginId = "dummy" + i + "@test.com"; + if (!existingLoginIds.contains(loginId)) { + int age = 14 + random.nextInt(67); + Gender gender = (i % 2 == 0) ? Gender.MALE : Gender.FEMALE; + Member member = Member.builder() + .loginId(loginId) + .password("password") + .age(age) + .gender(gender) + .name("투표자" + i) + .build(); + membersToSave.add(member); + } + } + if (!membersToSave.isEmpty()) { + memberRepository.saveAll(membersToSave); + } + return membersToSave.size(); + } + + @Transactional + public int dummyVote(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.isEmpty()) return 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; + Random random = new Random(); + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollOptions selectedOption = pollOptionsList.get(random.nextInt(pollOptionsList.size())); + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .member(member) + .pollOptions(selectedOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + + @Transactional + public int deleteDummyMembers() { + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + int count = dummyMembers.size(); + if (count > 0) { + for (Member member : dummyMembers) { + pollVoteRepository.deleteByMemberIdValue(member.getMemberId()); + } + memberRepository.deleteAll(dummyMembers); + } + return count; + } +} 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 67e4dabb..65860dea 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 @@ -7,6 +7,7 @@ import com.ai.lawyer.domain.post.dto.PostUpdateDto; import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.dto.PostSimpleDto; +import com.ai.lawyer.domain.poll.dto.PollDto.PollStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,8 +16,8 @@ public interface PostService { // ===== 조회 관련 ===== PostDetailDto getPostById(Long postId); - PostDetailDto getPostDetailById(Long postId); - List getAllPosts(); + PostDetailDto getPostDetailById(Long postId, Long memberId); + List getAllPosts(Long memberId); List getAllSimplePosts(); List getPostsByMemberId(Long memberId); @@ -30,9 +31,17 @@ public interface PostService { // ===== 본인 게시글 관련 ===== PostDto getMyPostById(Long postId, Long requesterMemberId); List getMyPosts(Long requesterMemberId); + Page getMyPostspaged(Pageable pageable, Long requesterMemberId); // ===== 페이징 관련 ===== - Page getPostsPaged(Pageable pageable); - Page getOngoingPostsPaged(Pageable pageable); - Page getClosedPostsPaged(Pageable pageable); + Page getPostsPaged(Pageable pageable, Long memberId); + Page getOngoingPostsPaged(Pageable pageable, Long memberId); + Page getClosedPostsPaged(Pageable pageable, Long memberId); + Page getMyOngoingPostsPaged(Pageable pageable, Long memberId); + Page getMyClosedPostsPaged(Pageable pageable, Long memberId); + Page getMyVotedPostsPaged(Pageable pageable, Long memberId); + + // ===== 투표 Top 관련 ===== + PostDto getTopPollByStatus(PollStatus status, Long memberId); + List getTopNPollsByStatus(PollStatus status, int n, Long memberId); } \ No newline at end of file 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 f20d91da..2024b2e6 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,7 +1,8 @@ package com.ai.lawyer.domain.post.service; import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; +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; import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; @@ -10,15 +11,14 @@ import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; -import com.ai.lawyer.domain.poll.repository.PollRepository; import com.ai.lawyer.domain.poll.entity.Poll; -import com.ai.lawyer.domain.poll.dto.PollCreateDto; -import com.ai.lawyer.domain.poll.dto.PollDto; -import com.ai.lawyer.domain.poll.dto.PollUpdateDto; -import com.ai.lawyer.domain.poll.repository.PollOptionsRepository; +import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollOptions; +import com.ai.lawyer.domain.poll.repository.PollRepository; +import com.ai.lawyer.domain.poll.repository.PollOptionsRepository; import com.ai.lawyer.domain.poll.repository.PollVoteRepository; import com.ai.lawyer.domain.poll.service.PollService; +import com.ai.lawyer.global.util.AuthUtil; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.PageImpl; @@ -28,22 +28,30 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; +import static com.ai.lawyer.domain.poll.entity.Poll.PollStatus.CLOSED; +import static com.ai.lawyer.domain.poll.entity.Poll.PollStatus.ONGOING; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + @Service public class PostServiceImpl implements PostService { private final PostRepository postRepository; - private final MemberRepository memberRepository; private final PollRepository pollRepository; private final PollOptionsRepository pollOptionsRepository; private final PollVoteRepository pollVoteRepository; private final PollService pollService; - public PostServiceImpl(PostRepository postRepository, MemberRepository memberRepository, PollRepository pollRepository, PollOptionsRepository pollOptionsRepository, PollVoteRepository pollVoteRepository, PollService pollService) { + public PostServiceImpl(PostRepository postRepository, + PollRepository pollRepository, + PollOptionsRepository pollOptionsRepository, + PollVoteRepository pollVoteRepository, + PollService pollService) { this.postRepository = postRepository; - this.memberRepository = memberRepository; this.pollRepository = pollRepository; this.pollOptionsRepository = pollOptionsRepository; this.pollVoteRepository = pollVoteRepository; @@ -53,32 +61,36 @@ public PostServiceImpl(PostRepository postRepository, MemberRepository memberRep @Override public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { if (postRequestDto.getPostName() == null || postRequestDto.getPostName().trim().isEmpty() || - postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { + postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); } - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() - .member(member) - .postName(postRequestDto.getPostName()) - .postContent(postRequestDto.getPostContent()) - .category(postRequestDto.getCategory()) - .createdAt(LocalDateTime.now()) - .build(); + .member(member) + .postName(postRequestDto.getPostName()) + .postContent(postRequestDto.getPostContent()) + .category(postRequestDto.getCategory()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); Post saved = postRepository.save(post); - return convertToDto(saved); + return convertToDto(saved, memberId); } - @Override - public PostDetailDto getPostById(Long postId) { - return getPostDetailById(postId); + public PostDetailDto getPostDetailById(Long postId, Long memberId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); + PostDto postDto = convertToDto(post, memberId); + return PostDetailDto.builder() + .post(postDto) + .build(); } @Override - public PostDetailDto getPostDetailById(Long postId) { + public PostDetailDto getPostById(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - PostDto postDto = convertToDto(post); + PostDto postDto = convertToDto(post, post.getMember().getMemberId()); return PostDetailDto.builder() .post(postDto) .build(); @@ -86,14 +98,14 @@ public PostDetailDto getPostDetailById(Long postId) { @Override public List getPostsByMemberId(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); List posts = postRepository.findByMember(member); if (posts.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); } return posts.stream() - .map(this::convertToDto) + .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) // 최신순 정렬 + .map(post -> convertToDto(post, memberId)) .collect(Collectors.toList()); } @@ -115,31 +127,31 @@ public PostDto updatePost(Long postId, PostUpdateDto postUpdateDto) { if (post.getPoll() == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "이 게시글에는 투표가 없어 투표 수정이 불가능합니다."); } - pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll()); + pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll(), post.getMember().getMemberId()); } if (postUpdateDto.getPostName() != null) post.setPostName(postUpdateDto.getPostName()); if (postUpdateDto.getPostContent() != null) post.setPostContent(postUpdateDto.getPostContent()); if (postUpdateDto.getCategory() != null) post.setCategory(postUpdateDto.getCategory()); - post.setCreatedAt(java.time.LocalDateTime.now()); // 수정 시 생성일 갱신 - - Post updated = postRepository.save(post); - return convertToDto(updated); + post.setUpdatedAt(LocalDateTime.now()); // 추가 + postRepository.save(post); + return convertToDto(post, post.getMember().getMemberId()); } @Override public void deletePost(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "삭제할 게시글을 찾을 수 없습니다.")); - // Poll도 명시적으로 삭제 (JPA cascade/orphanRemoval이 있으면 생략 가능) postRepository.delete(post); } @Override - public List getAllPosts() { - List posts = postRepository.findAll(); - return posts.stream() - .map(post -> getPostDetailById(post.getPostId())) + public List getAllPosts(Long memberId) { + return postRepository.findAll().stream() + .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) // 최신순 정렬 + .map(post -> PostDetailDto.builder() + .post(convertToDto(post, memberId)) + .build()) .collect(Collectors.toList()); } @@ -149,19 +161,25 @@ public PostDto getMyPostById(Long postId, Long requesterMemberId) { if (!post.getMember().getMemberId().equals(requesterMemberId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인 게시글만 조회할 수 있습니다."); } - return convertToDto(post); + return convertToDto(post, requesterMemberId); } public List getMyPosts(Long requesterMemberId) { - Member member = memberRepository.findById(requesterMemberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(requesterMemberId); List posts = postRepository.findByMember(member); - // 본인 게시글이 없으면 빈 리스트 반환 return posts.stream() - .map(this::convertToDto) + .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) + .map(post -> convertToDto(post, requesterMemberId)) .collect(Collectors.toList()); } + @Override + public Page getMyPostspaged(Pageable pageable, Long requesterMemberId) { + Member member = AuthUtil.getMemberOrThrow(requesterMemberId); + Page posts = postRepository.findByMember(member, pageable); + return posts.map(post -> convertToDto(post, requesterMemberId)); + } + @Override public void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto) { Post post = postRepository.findById(postId) @@ -179,13 +197,7 @@ public void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto) { if (postUpdateDto.getPostName() != null) post.setPostName(postUpdateDto.getPostName()); if (postUpdateDto.getPostContent() != null) post.setPostContent(postUpdateDto.getPostContent()); if (postUpdateDto.getCategory() != null) post.setCategory(postUpdateDto.getCategory()); - post.setCreatedAt(java.time.LocalDateTime.now()); // 수정 시 생성일 갱신 - - // 투표 수정이 요청된 경우 - if (postUpdateDto.getPoll() != null && post.getPoll() != null) { - PollUpdateDto pollUpdateDto = postUpdateDto.getPoll(); - pollService.patchUpdatePoll(post.getPoll().getPollId(), pollUpdateDto); - } + post.setUpdatedAt(LocalDateTime.now()); // 추가 postRepository.save(post); } @@ -194,105 +206,152 @@ public void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto) { public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId) { PostRequestDto postDto = dto.getPost(); if (postDto == null || postDto.getPostName() == null || postDto.getPostName().trim().isEmpty() || - postDto.getPostContent() == null || postDto.getPostContent().trim().isEmpty()) { + postDto.getPostContent() == null || postDto.getPostContent().trim().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); } var pollDto = dto.getPoll(); pollService.validatePollCreate(pollDto); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Member member = AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() - .member(member) - .postName(postDto.getPostName()) - .postContent(postDto.getPostContent()) - .category(postDto.getCategory()) - .createdAt(LocalDateTime.now()) - .build(); + .member(member) + .postName(postDto.getPostName()) + .postContent(postDto.getPostContent()) + .category(postDto.getCategory()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); Post savedPost = postRepository.save(post); Poll poll = Poll.builder() - .voteTitle(pollDto.getVoteTitle()) - .reservedCloseAt(pollDto.getReservedCloseAt()) - .createdAt(LocalDateTime.now()) - .status(Poll.PollStatus.ONGOING) - .post(savedPost) - .build(); + .voteTitle(pollDto.getVoteTitle()) + .reservedCloseAt(pollDto.getReservedCloseAt()) + .createdAt(LocalDateTime.now()) + .status(Poll.PollStatus.ONGOING) + .post(savedPost) + .build(); Poll savedPoll = pollRepository.save(poll); for (var optionDto : pollDto.getPollOptions()) { PollOptions option = PollOptions.builder() - .poll(savedPoll) - .option(optionDto.getContent()) - .build(); + .poll(savedPoll) + .option(optionDto.getContent()) + .build(); pollOptionsRepository.save(option); } savedPost.setPoll(savedPoll); postRepository.save(savedPost); - return getPostDetailById(savedPost.getPostId()); + return getPostDetailById(savedPost.getPostId(), memberId); } @Override public List getAllSimplePosts() { List posts = postRepository.findAll(); return posts.stream() - .map(post -> { - PostSimpleDto.PollInfo pollInfo = null; - if (post.getPoll() != null) { - pollInfo = PostSimpleDto.PollInfo.builder() - .pollId(post.getPoll().getPollId()) - .pollStatus(post.getPoll().getStatus().name()) - .build(); - } - return PostSimpleDto.builder() - .postId(post.getPostId()) - .memberId(post.getMember().getMemberId()) - .poll(pollInfo) - .build(); - }) - .collect(Collectors.toList()); + .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) // 최신순 정렬 + .map(post -> { + PostSimpleDto.PollInfo pollInfo = null; + if (post.getPoll() != null) { + pollInfo = PostSimpleDto.PollInfo.builder() + .pollId(post.getPoll().getPollId()) + .pollStatus(post.getPoll().getStatus().name()) + .build(); + } + return PostSimpleDto.builder() + .postId(post.getPostId()) + .memberId(post.getMember().getMemberId()) + .poll(pollInfo) + .build(); + }) + .collect(Collectors.toList()); } @Override - public Page getPostsPaged(Pageable pageable) { - return postRepository.findAll(pageable).map(this::convertToDto); + public Page getPostsPaged(Pageable pageable, Long memberId) { + Pageable sortedPageable = pageable; + if (pageable.getSort().isUnsorted() || pageable.getSort().getOrderFor("updatedAt") == null) { + sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by("updatedAt").descending() + ); + } + return postRepository.findAll(sortedPageable).map(post -> convertToDto(post, memberId)); } @Override - public Page getOngoingPostsPaged(Pageable pageable) { - Page allPosts = postRepository.findAll(pageable).map(this::convertToDto); - List ongoing = allPosts.stream() - .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == PollDto.PollStatus.ONGOING) - .collect(Collectors.toList()); - return new PageImpl<>(ongoing, pageable, ongoing.size()); + public Page getOngoingPostsPaged(Pageable pageable, Long memberId) { + Page posts = postRepository.findByPoll_Status(ONGOING, pageable); + return posts.map(post -> convertToDto(post, memberId)); } @Override - public Page getClosedPostsPaged(Pageable pageable) { - Page allPosts = postRepository.findAll(pageable).map(this::convertToDto); - List closed = allPosts.stream() - .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == PollDto.PollStatus.CLOSED) - .collect(Collectors.toList()); - return new PageImpl<>(closed, pageable, closed.size()); + public Page getClosedPostsPaged(Pageable pageable, Long memberId) { + Page posts = postRepository.findByPoll_Status(CLOSED, pageable); + return posts.map(post -> convertToDto(post, memberId)); + } + + private Page getMyVotedPostsPagedByStatus(Pageable pageable, Long memberId, Poll.PollStatus status) { + List votes = pollVoteRepository.findByMember_MemberId(memberId); + List pollIds = votes.stream().map(v -> v.getPoll().getPollId()).distinct().toList(); + Page posts = (status == null) + ? postRepository.findByPoll_PollIdIn(pollIds, pageable) + : postRepository.findByPoll_StatusAndPoll_PollIdIn(status, pollIds, pageable); + return posts.map(post -> convertToDto(post, memberId)); + } + + @Override + public Page getMyVotedPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, null); + } + + @Override + public Page getMyOngoingPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, Poll.PollStatus.ONGOING); + } + + @Override + public Page getMyClosedPostsPaged(Pageable pageable, Long memberId) { + return getMyVotedPostsPagedByStatus(pageable, memberId, Poll.PollStatus.CLOSED); + } + + @Override + public PostDto getTopPollByStatus(PollStatus status, Long memberId) { + return postRepository.findAll().stream() + .map(post -> convertToDto(post, memberId)) + .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == status) + .max(Comparator.comparing((PostDto dto) -> dto.getPoll().getTotalVoteCount() == null ? 0 : dto.getPoll().getTotalVoteCount())) + .orElse(null); + } + + @Override + public List getTopNPollsByStatus(PollStatus status, int n, Long memberId) { + return postRepository.findAll().stream() + .map(post -> convertToDto(post, memberId)) + .filter(dto -> dto.getPoll() != null && dto.getPoll().getStatus() == status) + .sorted(Comparator.comparing((PostDto dto) -> dto.getPoll().getTotalVoteCount() == null ? 0 : dto.getPoll().getTotalVoteCount()).reversed()) + .limit(n) + .collect(Collectors.toList()); } - private PostDto convertToDto(Post entity) { - Long memberId = null; + private PostDto convertToDto(Post entity, Long memberId) { + Long postMemberId = null; if (entity.getMember() != null) { - memberId = entity.getMember().getMemberId(); + postMemberId = entity.getMember().getMemberId(); } PollDto pollDto = null; if (entity.getPoll() != null) { if (entity.getPoll().getStatus() == Poll.PollStatus.CLOSED) { - pollDto = pollService.getPollWithStatistics(entity.getPoll().getPollId()); + pollDto = pollService.getPollWithStatistics(entity.getPoll().getPollId(), memberId); } else { - pollDto = pollService.getPoll(entity.getPoll().getPollId()); + pollDto = pollService.getPoll(entity.getPoll().getPollId(), memberId); } } return PostDto.builder() .postId(entity.getPostId()) - .memberId(memberId) + .memberId(postMemberId) .postName(entity.getPostName()) .postContent(entity.getPostContent()) .category(entity.getCategory()) .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) .poll(pollDto) .build(); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/controller/PrecedentController.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/controller/PrecedentController.java index d1ed9bc8..b737ef78 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/controller/PrecedentController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/controller/PrecedentController.java @@ -8,10 +8,12 @@ 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.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/precedent") @@ -37,18 +39,15 @@ public ResponseEntity list( @PostMapping("/search") @Operation(summary = "판례 목록 검색 기능", description = "조건에 맞는 판례 목록을 가져옵니다") - public ResponseEntity searchPrecedents( + public ResponseEntity searchPrecedents( @RequestBody PrecedentSearchRequestDto requestDto) { - - Page results = precedentService.searchByKeyword(requestDto); - PageResponseDto response = PageResponseDto.builder() - .content(results.getContent()) - .totalElements(results.getTotalElements()) - .totalPages(results.getTotalPages()) - .pageNumber(results.getNumber()) - .pageSize(results.getSize()) - .build(); - return ResponseEntity.ok(response); + try { + Page results = precedentService.searchByKeywordV2(requestDto); + return ResponseEntity.ok(PageResponseDto.from(results)); + }catch (Exception e){ + log.error("판례 목록 검색 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("판례 목록 검색 에러 : " + e.getMessage()); + } } /** @@ -61,8 +60,13 @@ public ResponseEntity searchPrecedents( @GetMapping("/{id}") @Operation(summary = "판례 상세 조회 기능", description = "판례 상세 데이터를 조회합니다 \n" + "예시: /api/precedent/1") - public ResponseEntity getPrecedent(@PathVariable Long id) { - Precedent precedent = precedentService.getPrecedentById(id); - return ResponseEntity.ok(precedent); + public ResponseEntity getPrecedent(@PathVariable Long id) { + try { + Precedent precedent = precedentService.getPrecedentById(id); + return ResponseEntity.ok(precedent); + }catch (Exception e){ + log.error("판례 상세 조회 에러 : " + e.getMessage()); + return ResponseEntity.badRequest().body("판례 상세 조회 에러 : " + e.getMessage()); + } } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java index 12ebf644..651520ef 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java @@ -2,6 +2,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import java.time.LocalDate; @@ -22,4 +24,8 @@ public class PrecedentSearchRequestDto { @Schema(description = "페이지 크기", example = "10") private int pageSize; // 페이지 크기 + + public Pageable toPageable() { + return PageRequest.of(pageNumber, pageSize); + } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepository.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepository.java index 95a090f3..f3c5e313 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepository.java @@ -2,13 +2,61 @@ import com.ai.lawyer.domain.precedent.entity.Precedent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; @Repository public interface PrecedentRepository extends JpaRepository, PrecedentRepositoryCustom { - /** - * 판례일련번호로 존재 여부 확인 - */ - boolean existsByPrecedentNumber(String precedentNumber); + @Query(value = """ + SELECT + id, + case_name, + case_number, + sentencing_date, + COALESCE( + NULLIF(summary_of_the_judgment, ''), + NULLIF(notice, ''), + NULLIF(precedent_content, ''), + '' + ) AS contents, + MATCH(notice, summary_of_the_judgment, precedent_content, case_name, case_number) + AGAINST (:keyword IN BOOLEAN MODE) AS relevance + FROM precedent + WHERE + (:keyword IS NULL OR :keyword = '' OR + MATCH(notice, summary_of_the_judgment, precedent_content, case_name, case_number) + AGAINST (:keyword IN BOOLEAN MODE) > 0) + AND (:startDate IS NULL OR sentencing_date >= :startDate) + AND (:endDate IS NULL OR sentencing_date <= :endDate) + ORDER BY relevance DESC, sentencing_date DESC + LIMIT :offset, :pageSize + """, nativeQuery = true) + List searchByKeywordNative( + @Param("keyword") String keyword, // "절도*" 형식 + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("offset") int offset, + @Param("pageSize") int pageSize + ); + + @Query(value = """ + SELECT COUNT(*) + FROM precedent + WHERE + (:keyword IS NULL OR :keyword = '' OR + MATCH(notice, summary_of_the_judgment, precedent_content, case_name, case_number) + AGAINST (:keyword IN BOOLEAN MODE) > 0) + AND (:startDate IS NULL OR sentencing_date >= :startDate) + AND (:endDate IS NULL OR sentencing_date <= :endDate) + """, nativeQuery = true) + Long countByKeywordNative( + @Param("keyword") String keyword, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/service/PrecedentService.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/service/PrecedentService.java index cdd1d885..0d8d2a50 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/service/PrecedentService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/service/PrecedentService.java @@ -10,6 +10,7 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -24,6 +25,7 @@ import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; +import java.util.stream.Collectors; @Service @AllArgsConstructor @@ -64,6 +66,65 @@ public Page searchByKeyword(PrecedentSearchRequestDto r return precedentRepository.searchPrecedentsByKeyword(requestDto); } + public Page searchByKeywordV2(PrecedentSearchRequestDto requestDto) { + String keyword = null; + if (StringUtils.hasText(requestDto.getKeyword())) { + keyword = requestDto.getKeyword().trim() + "*"; + } + + int offset = requestDto.getPageNumber() * requestDto.getPageSize(); + + List results = precedentRepository.searchByKeywordNative( + keyword, + requestDto.getSentencingDateStart(), + requestDto.getSentencingDateEnd(), + offset, + requestDto.getPageSize() + ); + + Long total = precedentRepository.countByKeywordNative( + keyword, + requestDto.getSentencingDateStart(), + requestDto.getSentencingDateEnd() + ); + + List content = results.stream() + .map(this::mapToDto) + .toList(); + + return new PageImpl<>(content, requestDto.toPageable(), total); + } + + private PrecedentSummaryListDto mapToDto(Object[] row) { + Long id = ((Number) row[0]).longValue(); + String caseName = (String) row[1]; + String caseNumber = (String) row[2]; + java.sql.Date sqlDate = (java.sql.Date) row[3]; + String contents = (String) row[4]; + + if (contents == null) contents = ""; + + return new PrecedentSummaryListDto( + id, + caseName, + caseNumber, + sqlDate != null ? sqlDate.toLocalDate() : null, + extractOrderPart(contents) + ); + } + + private String extractOrderPart(String contents) { + if (contents == null || contents.isBlank()) return ""; + + int start = contents.indexOf("【주 문】"); + if (start == -1) return contents; + + int end = contents.indexOf("【이 유】", start); + if (end == -1) end = contents.length(); + + return contents.substring(start, end).trim(); + } + /** * 특정 키워드로 법령 API에서 판례 일련번호 리스트 조회 * diff --git a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java index ffb31d77..59d6f7e0 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java @@ -1,5 +1,9 @@ package com.ai.lawyer.global.config; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -10,6 +14,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Slf4j @@ -53,4 +58,25 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC log.info("=== RedisTemplate 설정 완료 (host={}, port={}) ===", redisHost, redisPort); return redisTemplate; } + + @Bean + public RedisTemplate chatRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper); + + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } + + } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/dto/PageResponseDto.java b/backend/src/main/java/com/ai/lawyer/global/dto/PageResponseDto.java index 55f4f66b..13ccf6a9 100644 --- a/backend/src/main/java/com/ai/lawyer/global/dto/PageResponseDto.java +++ b/backend/src/main/java/com/ai/lawyer/global/dto/PageResponseDto.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.Data; +import org.springframework.data.domain.Page; import java.util.List; @@ -13,4 +14,14 @@ public class PageResponseDto { private int totalPages; private int pageNumber; private int pageSize; + + public static PageResponseDto from(Page page) { + return PageResponseDto.builder() + .content(page.getContent()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .pageNumber(page.getNumber()) + .pageSize(page.getSize()) + .build(); + } } diff --git a/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java b/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java index fade1a07..a2922d84 100644 --- a/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java +++ b/backend/src/main/java/com/ai/lawyer/global/initData/InitData.java @@ -13,7 +13,7 @@ import java.util.Optional; import java.util.regex.Pattern; - +import java.util.List; @Component public class InitData implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(InitData.class); @@ -34,30 +34,33 @@ public InitData(MemberRepository memberRepository, @Transactional public void run(String... args) throws Exception { //관리자 로그인 아이디 - String targetLoginId = "admin@example.com"; - log.info("InitData: checking password encoding for [{}]", targetLoginId); + List targetLoginIds = List.of("admin@example.com", "admin@test.com"); - Optional opt = memberRepository.findByLoginId(targetLoginId); - if (opt.isEmpty()) { - log.info("InitData: target account not found [{}]. 아무 작업도 수행하지 않습니다.", targetLoginId); - return; - } + for (String loginId : targetLoginIds) { + log.info("InitData: checking password encoding for [{}]", loginId); - Member member = opt.get(); - String stored = member.getPassword(); + Optional opt = memberRepository.findByLoginId(loginId); + if (opt.isEmpty()) { + log.info("InitData: target account not found [{}]. 아무 작업도 수행하지 않습니다.", loginId); + continue; + } - if (isBcryptHash(stored)) { - log.info("InitData: {} 계정의 비밀번호는 이미 bcrypt 해시입니다. 변경 없음.", targetLoginId); - return; - } + Member member = opt.get(); + String stored = member.getPassword(); - // 여기서 stored는 평문으로 추정됨 -> 절대 로그에 찍지 않음 - String encoded = passwordEncoder.encode(stored); + if (isBcryptHash(stored)) { + log.info("InitData: {} 계정의 비밀번호는 이미 bcrypt 해시입니다. 변경 없음.", loginId); + continue; + } - member.updatePassword(encoded); - memberRepository.save(member); + // 여기서 stored는 평문으로 추정됨 -> 절대 로그에 찍지 않음 + String encoded = passwordEncoder.encode(stored); - log.info("InitData: {} 계정의 비밀번호를 안전하게 암호화하여 저장했습니다.", targetLoginId); + member.updatePassword(encoded); + memberRepository.save(member); + + log.info("InitData: {} 계정의 비밀번호를 안전하게 암호화하여 저장했습니다.", loginId); + } } private boolean isBcryptHash(String s) { 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 a498ae6f..8de82fd1 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 @@ -3,11 +3,14 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; import java.time.Duration; +@Slf4j @Component public class CookieUtil { @@ -18,7 +21,7 @@ public class CookieUtil { // 쿠키 만료 시간 상수 (초 단위) private static final int MINUTES_PER_HOUR = 60; private static final int HOURS_PER_DAY = 24; - private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * 60; // 5분 (300초) + private static final int ACCESS_TOKEN_EXPIRE_TIME = 60 * 60; // 5분 (300초) private static final int REFRESH_TOKEN_EXPIRE_TIME = 7 * HOURS_PER_DAY * MINUTES_PER_HOUR * 60; // 7일 // 쿠키 보안 설정 상수 @@ -28,6 +31,9 @@ public class CookieUtil { private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용 private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; + @Value("${custom.cookie.domain:}") + private String cookieDomain; + public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { setAccessTokenCookie(response, accessToken); setRefreshTokenCookie(response, refreshToken); @@ -52,13 +58,27 @@ public void clearTokenCookies(HttpServletResponse response) { * ResponseCookie를 생성합니다 (SameSite 지원). */ private ResponseCookie createResponseCookie(String name, String value, int maxAge) { - return ResponseCookie.from(name, value) + log.debug("=== 쿠키 생성 중: name={}, cookieDomain='{}', isEmpty={}", + name, cookieDomain, cookieDomain == null || cookieDomain.isEmpty()); + + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) .httpOnly(HTTP_ONLY) .secure(SECURE_IN_PRODUCTION) .path(COOKIE_PATH) .maxAge(Duration.ofSeconds(maxAge)) - .sameSite(SAME_SITE) - .build(); + .sameSite(SAME_SITE); + + // 도메인이 설정되어 있으면 추가 + if (cookieDomain != null && !cookieDomain.isEmpty()) { + log.debug("쿠키 도메인 설정: {}", cookieDomain); + builder.domain(cookieDomain); + } else { + log.debug("쿠키 도메인 설정 안 함 (빈 값 또는 null)"); + } + + ResponseCookie cookie = builder.build(); + log.debug("생성된 쿠키: {}", cookie); + return cookie; } /** 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 13792696..896624f5 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 @@ -52,13 +52,16 @@ public class SecurityConfig { "/swagger-ui/**", // Swagger UI "/swagger-ui.html", // Swagger UI HTML "/api/posts/**", // 게시글 (공개) + "/api/polls/{pollId}/statics", // 투표 통계 (공개) "/api/precedent/**", // 판례 (공개) "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개) "/api/chat/**", // 챗봇 (공개) + "/api/home/**", // 홈 (공개) "/h2-console/**", // H2 콘솔 (개발용) "/actuator/health", "/actuator/health/**", "/actuator/info", // Spring Actuator - "/api/actuator/health", "/api/actuator/health/**", "/api/actuator/info" + "/api/actuator/health", "/api/actuator/health/**", "/api/actuator/info", + "/sentry-test" // Sentry 테스트용 }; // CORS 허용 메서드 diff --git a/backend/src/main/java/com/ai/lawyer/global/sentry/SentryManualConfig.java b/backend/src/main/java/com/ai/lawyer/global/sentry/SentryManualConfig.java new file mode 100644 index 00000000..fb07788c --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/sentry/SentryManualConfig.java @@ -0,0 +1,40 @@ +package com.ai.lawyer.global.sentry; + +import io.sentry.Sentry; +import io.sentry.SentryOptions; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class SentryManualConfig { + + @Value("${sentry.dsn:}") + private String sentryDsn; + + @Value("${sentry.environment:prod}") + private String environment; + + @Value("${sentry.release:unknown}") + private String release; + + @PostConstruct + public void init() { + if (sentryDsn == null || sentryDsn.isBlank()) { + log.warn("Sentry DSN not set — skipping Sentry init."); + return; + } + + Sentry.init(options -> { + options.setDsn(sentryDsn); + options.setEnvironment(environment); + options.setRelease(release); + options.setSendDefaultPii(true); + options.setEnableExternalConfiguration(true); + options.setTracesSampleRate(1.0); // 100% 트레이스 수집 + log.info("Sentry manually initialized (forcing enable)"); + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/sentry/SentryTestController.java b/backend/src/main/java/com/ai/lawyer/global/sentry/SentryTestController.java new file mode 100644 index 00000000..9ccc8319 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/sentry/SentryTestController.java @@ -0,0 +1,19 @@ +package com.ai.lawyer.global.sentry; + +import io.sentry.Sentry; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SentryTestController { + + @GetMapping("/sentry-test") + public String triggerError() { + try { + throw new RuntimeException("Sentry test error - Balaw 프로젝트"); + } catch (Exception e) { + Sentry.captureException(e); + } + return "Error sent to Sentry!"; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java index 52b9a768..48a9033a 100644 --- a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java @@ -68,7 +68,8 @@ public GroupedOpenApi allApi() { .build(); } - @Bean GroupedOpenApi chatApi() { + @Bean + GroupedOpenApi chatApi() { return GroupedOpenApi.builder() .group("챗봇과 관련된 API") .pathsToMatch("/api/chat/**") @@ -76,6 +77,15 @@ public GroupedOpenApi allApi() { .build(); } + @Bean + GroupedOpenApi homeApi() { + return GroupedOpenApi.builder() + .group("Home API") + .pathsToMatch("/api/home/**") + .packagesToScan("com.ai.lawyer.domain.home.controller") + .build(); + } + @Bean public OpenAPI customOpenAPI() { return new OpenAPI() 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 new file mode 100644 index 00000000..aa8a7a66 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java @@ -0,0 +1,82 @@ +package com.ai.lawyer.global.util; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.entity.Member; + +@Component +public class AuthUtil { + private static MemberRepository memberRepository; + + @Autowired + public AuthUtil(MemberRepository memberRepository) { + AuthUtil.memberRepository = memberRepository; + } + + public static Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + 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; + } + } else if (principal instanceof String str) { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return null; + } + } else if (principal instanceof Long l) { + return l; + } + } + return null; + } + + public static String getCurrentMemberRole() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return authentication.getAuthorities().stream() + .findFirst() + .map(auth -> auth.getAuthority()) + .orElse(null); + } + + public static Member getMemberOrThrow(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); + } + + public static Long getAuthenticatedMemberId() { + try { + Long memberId = getCurrentMemberId(); + if (memberId == null) { + throw new IllegalArgumentException(); + } + return memberId; + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다"); + } + } + + public static void validateOwnerOrAdmin(Long ownerId) { + Long currentMemberId = getAuthenticatedMemberId(); + String currentRole = getCurrentMemberRole(); + if (!ownerId.equals(currentMemberId) && !"ADMIN".equals(currentRole)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인 또는 관리자만 수정 가능합니다."); + } + } + +} diff --git a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java new file mode 100644 index 00000000..e5b53c43 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java @@ -0,0 +1,156 @@ +package com.ai.lawyer.infrastructure.kafka.consumer; + +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.domain.chatbot.service.KeywordService; +import com.ai.lawyer.infrastructure.kafka.dto.ChatPostProcessEvent; +import com.ai.lawyer.infrastructure.kafka.dto.DocumentDto; +import com.ai.lawyer.infrastructure.redis.service.ChatCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatPostProcessingConsumer { + + private final KeywordService keywordService; + private final ChatCacheService chatCacheService; + + private final HistoryRepository historyRepository; + private final ChatRepository chatRepository; + private final KeywordRankRepository keywordRankRepository; + private final ChatMemoryRepository chatMemoryRepository; + private final ChatPrecedentRepository chatPrecedentRepository; + private final ChatLawRepository chatLawRepository; + + @Value("${custom.ai.title-extraction}") + private String titleExtraction; + @Value("${custom.ai.keyword-extraction}") + private String keywordExtraction; + + @KafkaListener(topics = "chat-post-processing", groupId = "chat-processing-group") + @Transactional + public void consume(ChatPostProcessEvent event) { + try { + History history = historyRepository.findById(event.getHistoryId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다. historyId: " + event.getHistoryId())); + + // 1. 메시지 기억 저장 (Assistant 응답) + ChatMemory chatMemory = MessageWindowChatMemory.builder() + .maxMessages(10) + .chatMemoryRepository(chatMemoryRepository) + .build(); + + chatMemory.add(String.valueOf(history.getHistoryId()), new AssistantMessage(event.getChatResponse())); + chatMemoryRepository.saveAll(String.valueOf(history.getHistoryId()), chatMemory.get(String.valueOf(history.getHistoryId()))); + + // 2. 채팅방 제목 설정 / 및 필터 + setHistoryTitle(event.getUserMessage(), history, event.getChatResponse()); + + // 3. 채팅 기록 저장 및 Redis 캐시 저장 + saveChatWithDocuments(history, MessageType.USER, event.getUserMessage(), event.getSimilarCaseDocuments(), event.getSimilarLawDocuments()); + saveChatWithDocuments(history, MessageType.ASSISTANT, event.getChatResponse(), event.getSimilarCaseDocuments(), event.getSimilarLawDocuments()); + + // 4. 키워드 추출 및 랭킹 업데이트 + if (!event.getChatResponse().contains("해당 질문은 법률")) { + extractAndUpdateKeywordRanks(event.getUserMessage()); + } + } catch (Exception e) { + log.error("Kafka 이벤트 처리 중 에러 발생 (historyId: {}): ", event.getHistoryId(), e); + } + } + + private void setHistoryTitle(String userMessage, History history, String fullResponse) { + String targetText = fullResponse.contains("해당 질문은 법률") ? userMessage : fullResponse; + TitleExtractionDto titleDto = keywordService.keywordExtract(targetText, titleExtraction, TitleExtractionDto.class); + history.setTitle(titleDto.getTitle()); + historyRepository.save(history); + } + + private void extractAndUpdateKeywordRanks(String message) { + KeywordExtractionDto keywordResponse = keywordService.keywordExtract(message, keywordExtraction, KeywordExtractionDto.class); + if (keywordResponse == null || keywordResponse.getKeyword() == null) { + return; + } + + KeywordRank keywordRank = keywordRankRepository.findByKeyword(keywordResponse.getKeyword()); + + if (keywordRank == null) { + keywordRank = KeywordRank.builder() + .keyword(keywordResponse.getKeyword()) + .score(1L) + .build(); + } else { + keywordRank.setScore(keywordRank.getScore() + 1); + } + keywordRankRepository.save(keywordRank); + } + + private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + + List chatPrecedents = new ArrayList<>(); + List chatLaws = new ArrayList<>(); + + Chat chat = chatRepository.save(Chat.builder() + .historyId(history) + .type(type) + .message(message) + .build()); + + // Ai 메시지가 저장될 때 관련 문서 저장 + if (type == MessageType.ASSISTANT) { + if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { + chatPrecedents = similarCaseDocuments.stream() + .map(doc -> ChatPrecedent.builder() + .chatId(chat) + .precedentContent(doc.getText()) + .caseNumber(doc.getMetadata().get("caseNumber").toString()) + .caseName(doc.getMetadata().get("caseName").toString()) + .build()) + .collect(Collectors.toList()); + chatPrecedentRepository.saveAll(chatPrecedents); + } + + if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { + chatLaws = similarLawDocuments.stream() + .map(doc -> ChatLaw.builder() + .chatId(chat) + .content(doc.getText()) + .lawName(doc.getMetadata().get("lawName").toString()) + .build()) + .collect(Collectors.toList()); + chatLawRepository.saveAll(chatLaws); + } + } + + // 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/infrastructure/kafka/dto/ChatPostProcessEvent.java b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/dto/ChatPostProcessEvent.java new file mode 100644 index 00000000..07af90d3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/dto/ChatPostProcessEvent.java @@ -0,0 +1,18 @@ +package com.ai.lawyer.infrastructure.kafka.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatPostProcessEvent { + private Long historyId; + private String userMessage; + private String chatResponse; + private List similarCaseDocuments; + private List similarLawDocuments; +} diff --git a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/dto/DocumentDto.java b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/dto/DocumentDto.java new file mode 100644 index 00000000..9a77e650 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/dto/DocumentDto.java @@ -0,0 +1,20 @@ +package com.ai.lawyer.infrastructure.kafka.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.ai.document.Document; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DocumentDto { + private String text; + private Map metadata; + + public static DocumentDto from(Document document) { + return new DocumentDto(document.getText(), document.getMetadata()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/infrastructure/redis/service/ChatCacheService.java b/backend/src/main/java/com/ai/lawyer/infrastructure/redis/service/ChatCacheService.java new file mode 100644 index 00000000..7d50dc85 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/redis/service/ChatCacheService.java @@ -0,0 +1,35 @@ +package com.ai.lawyer.infrastructure.redis.service; + +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ChatCacheService { + + private final RedisTemplate chatRedisTemplate; + private static final String CHAT_HISTORY_KEY_PREFIX = "chat:history:"; + + // 채팅 메시지 캐싱 (24시간) + public void cacheChatMessage(Long roomId, ChatHistoryDto chatHistory) { + String key = CHAT_HISTORY_KEY_PREFIX + roomId; + chatRedisTemplate.opsForList().rightPush(key, chatHistory); + chatRedisTemplate.expire(key, Duration.ofHours(24)); + } + + public List getChatHistory(Long roomId) { + String key = CHAT_HISTORY_KEY_PREFIX + roomId; + List cachedList = chatRedisTemplate.opsForList().range(key, 0, -1); + return cachedList == null ? List.of() : cachedList; + } + + public void clearChatHistory(Long roomId) { + chatRedisTemplate.delete(CHAT_HISTORY_KEY_PREFIX + roomId); + } + +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 9320def5..efdd5128 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -17,12 +17,32 @@ 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 # 최소 한번 시작 jdbc: initialize-schema: always + task: + scheduling: + shutdown: + await-termination: true + await-termination-period: 60s + lifecycle: + timeout-per-shutdown-phase: 60s + datasource-meta: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/meta_db?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true @@ -50,3 +70,5 @@ custom: failure-url: ${DEV_OAUTH2_FAILURE_REDIRECT_URL} frontend: url: ${DEV_FRONTEND_URL} + cookie: + domain: ${DEV_COOKIE_DOMAIN} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index f9e31f08..9fa63b62 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -33,6 +33,24 @@ spring: port: ${PROD_REDIS_PORT} password: ${PROD_REDIS_PASSWORD} embedded: false + kafka: + bootstrap-servers: kafka: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: "*" + task: + scheduling: + shutdown: + await-termination: true + await-termination-period: 60s + lifecycle: + timeout-per-shutdown-phase: 60s security: oauth2: @@ -63,6 +81,8 @@ custom: failure-url: ${PROD_OAUTH2_FAILURE_REDIRECT_URL} frontend: url: ${PROD_FRONTEND_URL} + cookie: + domain: ${PROD_COOKIE_DOMAIN:.trybalaw.com} # 운영환경: 모든 서브도메인에서 쿠키 공유 sentry: dsn: ${PROD_SENTRY_DSN} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index fb9187f4..1e2e2581 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -155,3 +155,5 @@ custom: failure-url: ${CUSTOM_OAUTH2_FAILURE_URL} frontend: url: ${CUSTOM_FRONTEND_URL} + cookie: + domain: diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql deleted file mode 100644 index 7068cde4..00000000 --- a/backend/src/main/resources/data.sql +++ /dev/null @@ -1 +0,0 @@ -/**/ \ No newline at end of file diff --git a/backend/src/main/resources/system-prompt.yml b/backend/src/main/resources/system-prompt.yml index 91139cfd..cb2169d7 100644 --- a/backend/src/main/resources/system-prompt.yml +++ b/backend/src/main/resources/system-prompt.yml @@ -28,32 +28,29 @@ custom: keyword-extraction: | 당신은 키워드 추출기입니다. 반드시 아래 지침을 절대적으로 따라야 합니다. - 1. 오직 **가장 중요하고 대표적인 키워드 하나만** 추출해야 합니다. - 2. 여러 개의 키워드를 추출하거나, 콤마(,)나 공백으로 구분된 여러 단어를 하나의 문자열로 합치는 것은 금지입니다. - 3. 반드시 JSON 형식으로만 답변해야 하며, 'keyword' 리스트에는 문자열이 단 하나만 포함되어야 합니다. - 4. 예시 외의 설명, 추가 텍스트, 주석은 절대 출력하지 마세요. JSON만 출력하세요. + 1. 오직 가장 중요하고 대표적인 키워드 하나만 추출해야 합니다. + 2. 여러 개의 키워드를 추출하거나, 콤마(,)로 구분된 여러 단어를 하나로 합치는 것은 금지입니다. + 3. 반드시 JSON 형식으로만 답변해야 하며, 하나의 객체만 출력해야 합니다. + 4. JSON 이외의 설명, 주석, 텍스트는 절대 출력하지 마세요. --- 올바른 예시: 입력: "아파트 층간소음 문제로 다투던 중 이웃을 폭행하여 상해를 입혔습니다." 출력: - [ - { - "id": 1, - "keyword": "폭행" - "socre": 1 - } - ] + { + "id": 1, + "keyword": "층간소음 폭행", + "score": 1 + } 잘못된 예시: [ { "id": 1, - "keyword": "폭행, 상해, 층간소음, 이웃" - "socre": 1 + "keyword": "폭행, 상해, 층간소음, 이웃", + "score": 1 } ] --- 이제 아래 문장에서 위 규칙을 엄격히 준수하여 JSON만 출력하세요: - """ \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java index a432edb9..549e9027 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java @@ -57,6 +57,24 @@ class MemberServiceOAuth2Test { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatRepository chatRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository chatPrecedentRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatLawRepository chatLawRepository; + @Mock private HttpServletResponse response; @@ -74,7 +92,13 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository, + chatRepository, + chatPrecedentRepository, + chatLawRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 1c06e932..0b0659c2 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -52,6 +52,24 @@ class MemberServiceTest { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatRepository chatRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatPrecedentRepository chatPrecedentRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.ChatLawRepository chatLawRepository; + @Mock private HttpServletResponse response; @@ -73,7 +91,13 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository, + chatRepository, + chatPrecedentRepository, + chatLawRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); @@ -300,7 +324,21 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then + // 1. 회원 조회 verify(memberRepository).findByLoginId(loginId); + + // 2. 연관 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) + verify(chatPrecedentRepository).deleteByMemberIdValue(member.getMemberId()); + verify(chatLawRepository).deleteByMemberIdValue(member.getMemberId()); + verify(chatRepository).deleteByMemberIdValue(member.getMemberId()); + verify(historyRepository).deleteByMemberIdValue(member.getMemberId()); + verify(pollVoteRepository).deleteByMemberIdValue(member.getMemberId()); + verify(postRepository).deleteByMemberIdValue(member.getMemberId()); + + // 3. Redis 토큰 삭제 + verify(tokenProvider).deleteAllTokens(loginId); + + // 4. 회원 삭제 verify(memberRepository).delete(member); } diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index 9e92d267..daa05eb3 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -1,36 +1,55 @@ package com.ai.lawyer.domain.poll.controller; import com.ai.lawyer.domain.poll.service.PollService; -import org.junit.jupiter.api.Test; +import com.ai.lawyer.domain.poll.dto.PollDto; +import com.ai.lawyer.domain.poll.dto.PollVoteDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; +import com.ai.lawyer.global.jwt.TokenProvider; +import com.ai.lawyer.global.security.SecurityConfig; +import com.ai.lawyer.domain.post.service.PostService; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.global.jwt.CookieUtil; +import com.ai.lawyer.global.oauth.CustomOAuth2UserService; +import com.ai.lawyer.global.oauth.OAuth2SuccessHandler; +import com.ai.lawyer.global.oauth.OAuth2FailureHandler; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.ai.lawyer.domain.poll.dto.PollDto; -import com.ai.lawyer.domain.poll.dto.PollVoteDto; -import org.springframework.context.annotation.Import; -import com.ai.lawyer.global.security.SecurityConfig; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.junit.jupiter.api.DisplayName; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.data.redis.core.RedisTemplate; import jakarta.servlet.http.Cookie; + import static org.mockito.BDDMockito.*; -import com.ai.lawyer.global.jwt.TokenProvider; -import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration; @Import(SecurityConfig.class) @AutoConfigureMockMvc @WebMvcTest( - controllers = PollController.class, - excludeAutoConfiguration = { - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class, - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration.class, - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class, - org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration.class - } + controllers = PollController.class, + excludeAutoConfiguration = { + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class, + DataSourceAutoConfiguration.class, + JpaBaseConfiguration.class + } ) class PollControllerTest { @Autowired @@ -38,23 +57,23 @@ class PollControllerTest { @MockitoBean private PollService pollService; @MockitoBean - private com.ai.lawyer.domain.post.service.PostService postService; + private PostService postService; @MockitoBean - private com.ai.lawyer.global.jwt.TokenProvider tokenProvider; + private TokenProvider tokenProvider; @MockitoBean - private com.ai.lawyer.global.jwt.CookieUtil cookieUtil; + private CookieUtil cookieUtil; @MockitoBean - private com.ai.lawyer.domain.member.repositories.MemberRepository memberRepository; + private MemberRepository memberRepository; @MockitoBean - private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; + private JpaMetamodelMappingContext jpaMappingContext; @MockitoBean - private org.springframework.data.redis.core.RedisTemplate redisTemplate; + private RedisTemplate redisTemplate; @MockitoBean - private com.ai.lawyer.global.oauth.CustomOAuth2UserService customOAuth2UserService; + private CustomOAuth2UserService customOAuth2UserService; @MockitoBean - private com.ai.lawyer.global.oauth.OAuth2SuccessHandler oauth2SuccessHandler; + private OAuth2SuccessHandler oauth2SuccessHandler; @MockitoBean - private com.ai.lawyer.global.oauth.OAuth2FailureHandler oauth2FailureHandler; + private OAuth2FailureHandler oauth2FailureHandler; @BeforeEach void setUp() { @@ -69,30 +88,20 @@ void setUp() { @Test @DisplayName("투표 단일 조회") void t1() throws Exception { - Mockito.when(pollService.getPoll(Mockito.anyLong())).thenReturn(null); + Mockito.when(pollService.getPoll(Mockito.anyLong(), Mockito.anyLong())).thenReturn(null); mockMvc.perform(get("/api/polls/1") - .cookie(new Cookie("accessToken", "valid-access-token"))) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("투표 옵션 목록 조회") - void t2() throws Exception { - Mockito.when(pollService.getPollOptions(Mockito.anyLong())).thenReturn(java.util.Collections.emptyList()); - - mockMvc.perform(get("/api/polls/1/options") - .cookie(new Cookie("accessToken", "valid-access-token"))) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표하기") - void t3() throws Exception { + void t2() throws Exception { Mockito.when(pollService.vote(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(null); mockMvc.perform( - org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls/1/vote") + post("/api/polls/1/vote") .param("pollItemsId", "1") .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); @@ -100,64 +109,66 @@ void t3() throws Exception { @Test @DisplayName("투표 통계 조회") - void t4() throws Exception { + void t3() throws Exception { Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(new PollStaticsResponseDto()); mockMvc.perform(get("/api/polls/1/statics") - .cookie(new Cookie("accessToken", "valid-access-token"))) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표 종료") - void t5() throws Exception { + void t4() throws Exception { Mockito.doNothing().when(pollService).closePoll(Mockito.anyLong()); mockMvc.perform( - org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/polls/1/close") + put("/api/polls/1/close") .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("투표 삭제") - void t6() throws Exception { - Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong()); + void t5() throws Exception { + PollDto pollDto = PollDto.builder().pollId(1L).postId(1L).build(); + Mockito.when(pollService.getPoll(Mockito.eq(1L), Mockito.anyLong())).thenReturn(pollDto); + Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong(), Mockito.anyLong()); mockMvc.perform( - org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/api/polls/1") + delete("/api/polls/1") .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("진행중인 투표 Top 1 조회") - void t7() throws Exception { - Mockito.when(pollService.getTopPollByStatus(Mockito.any())).thenReturn(null); + void t6() throws Exception { + Mockito.when(pollService.getTopPollByStatus(Mockito.any(), Mockito.anyLong())).thenReturn(null); mockMvc.perform(get("/api/polls/top/ongoing") - .cookie(new Cookie("accessToken", "valid-access-token"))) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("종료된 투표 Top 1 조회") - void t8() throws Exception { - Mockito.when(pollService.getTopPollByStatus(Mockito.any())).thenReturn(null); + void t7() throws Exception { + Mockito.when(pollService.getTopPollByStatus(Mockito.any(), Mockito.anyLong())).thenReturn(null); mockMvc.perform(get("/api/polls/top/closed") - .cookie(new Cookie("accessToken", "valid-access-token"))) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표 생성") - void t9() throws Exception { + void t8() throws Exception { Mockito.when(pollService.createPoll(Mockito.any(), Mockito.anyLong())).thenReturn(null); mockMvc.perform( - org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls") - .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + post("/api/polls") + .contentType(MediaType.APPLICATION_JSON) .content("{}") .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); @@ -165,29 +176,29 @@ void t9() throws Exception { @Test @DisplayName("투표 단일 조회") - void t10() throws Exception { + void t9() throws Exception { PollDto responseDto = PollDto.builder().pollId(1L).voteTitle("테스트 투표").build(); - Mockito.when(pollService.getPoll(Mockito.anyLong())).thenReturn(responseDto); + Mockito.when(pollService.getPoll(Mockito.anyLong(), Mockito.anyLong())).thenReturn(responseDto); mockMvc.perform(get("/api/polls/1") - .cookie(new Cookie("accessToken", "valid-access-token"))) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.voteTitle").value("테스트 투표")); + .andExpect(MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) + .andExpect(MockMvcResultMatchers.jsonPath("$.result.voteTitle").value("테스트 투표")); } @Test @DisplayName("투표하기") - void t11() throws Exception { + void t10() throws Exception { PollVoteDto responseDto = PollVoteDto.builder().pollId(1L).memberId(1L).build(); Mockito.when(pollService.vote(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(responseDto); mockMvc.perform( - org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls/1/vote") - .param("pollItemsId", "1") - .cookie(new Cookie("accessToken", "valid-access-token")) - ).andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.memberId").value(1L)); + post("/api/polls/1/vote") + .param("pollItemsId", "1") + .cookie(new Cookie("accessToken", "valid-access-token")) + ).andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) + .andExpect(MockMvcResultMatchers.jsonPath("$.result.memberId").value(1L)); } } \ No newline at end of file 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 c47df427..7d6b8f94 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 @@ -112,7 +112,7 @@ void autoCloseTest() { poll.setReservedCloseAt(LocalDateTime.now().minusSeconds(1)); poll.setStatus(Poll.PollStatus.CLOSED); given(pollRepository.findById(eq(1L))).willReturn(java.util.Optional.of(poll)); - PollDto closed = pollService.getPoll(1L); + PollDto closed = pollService.getPoll(1L, 1L); assertThat(closed.getStatus()).isEqualTo(PollDto.PollStatus.CLOSED); } } diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java index 2a3cdebb..045ba04a 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java @@ -12,7 +12,9 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.springframework.web.server.ResponseStatusException; +import java.util.List; import java.util.Collections; +import org.springframework.http.HttpStatus; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -29,17 +31,17 @@ void setUp() { @DisplayName("투표 단일 조회") void t1() { PollDto expected = new PollDto(); - Mockito.when(pollService.getPoll(Mockito.anyLong())).thenReturn(expected); - PollDto result = pollService.getPoll(1L); + Mockito.when(pollService.getPoll(Mockito.anyLong(), Mockito.anyLong())).thenReturn(expected); + PollDto result = pollService.getPoll(1L, 1L); assertThat(result).isEqualTo(expected); } @Test @DisplayName("투표 옵션 목록 조회") void t2() { - java.util.List expected = java.util.Collections.emptyList(); + List expected = Collections.emptyList(); Mockito.when(pollService.getPollOptions(Mockito.anyLong())).thenReturn(expected); - java.util.List result = pollService.getPollOptions(1L); + List result = pollService.getPollOptions(1L); assertThat(result).isEqualTo(expected); } @@ -72,17 +74,17 @@ void t5() { @Test @DisplayName("투표 삭제") void t6() { - Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong()); - pollService.deletePoll(1L); - Mockito.verify(pollService).deletePoll(1L); + Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong(), Mockito.anyLong()); + pollService.deletePoll(1L, 1L); + Mockito.verify(pollService).deletePoll(1L, 1L); } @Test @DisplayName("상태별 Top 투표 조회") void t7() { PollDto expected = new PollDto(); - Mockito.when(pollService.getTopPollByStatus(Mockito.any())).thenReturn(expected); - PollDto result = pollService.getTopPollByStatus(PollDto.PollStatus.ONGOING); + Mockito.when(pollService.getTopPollByStatus(Mockito.any(), Mockito.anyLong())).thenReturn(expected); + PollDto result = pollService.getTopPollByStatus(PollDto.PollStatus.ONGOING, 1L); assertThat(result).isEqualTo(expected); } @@ -107,8 +109,8 @@ void t9() { void t10() { PollDto expected = new PollDto(); PollUpdateDto updateDto = new PollUpdateDto(); - Mockito.when(pollService.updatePoll(Mockito.anyLong(), Mockito.any())).thenReturn(expected); - PollDto result = pollService.updatePoll(1L, updateDto); + Mockito.when(pollService.updatePoll(Mockito.anyLong(), Mockito.any(), Mockito.anyLong())).thenReturn(expected); + PollDto result = pollService.updatePoll(1L, updateDto, 1L); assertThat(result).isEqualTo(expected); } @@ -116,8 +118,8 @@ void t10() { @DisplayName("통계 포함 투표 조회") void t11() { PollDto expected = new PollDto(); - Mockito.when(pollService.getPollWithStatistics(Mockito.anyLong())).thenReturn(expected); - PollDto result = pollService.getPollWithStatistics(1L); + Mockito.when(pollService.getPollWithStatistics(Mockito.anyLong(), Mockito.isNull())).thenReturn(expected); + PollDto result = pollService.getPollWithStatistics(1L, null); assertThat(result).isEqualTo(expected); } @@ -143,18 +145,18 @@ void t13() { @Test @DisplayName("상태별 투표 목록 조회") void t14() { - java.util.List expected = java.util.Collections.emptyList(); - Mockito.when(pollService.getPollsByStatus(Mockito.any())).thenReturn(expected); - java.util.List result = pollService.getPollsByStatus(PollDto.PollStatus.ONGOING); + List expected = Collections.emptyList(); + Mockito.when(pollService.getPollsByStatus(Mockito.any(), Mockito.anyLong())).thenReturn(expected); + List result = pollService.getPollsByStatus(PollDto.PollStatus.ONGOING, 1L); assertThat(result).isEqualTo(expected); } @Test @DisplayName("상태별 Top N 투표 목록 조회") void t15() { - java.util.List expected = java.util.Collections.emptyList(); - Mockito.when(pollService.getTopNPollsByStatus(Mockito.any(), Mockito.anyInt())).thenReturn(expected); - java.util.List result = pollService.getTopNPollsByStatus(PollDto.PollStatus.ONGOING, 3); + List expected = Collections.emptyList(); + Mockito.when(pollService.getTopNPollsByStatus(Mockito.any(), Mockito.anyInt(), Mockito.anyLong())).thenReturn(expected); + List result = pollService.getTopNPollsByStatus(PollDto.PollStatus.ONGOING, 3, 1L); assertThat(result).isEqualTo(expected); } @@ -163,7 +165,7 @@ void t15() { void t16() { PollCreateDto dto = new PollCreateDto(); dto.setVoteTitle("테스트 투표"); - Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "게시글 ID(postId)는 필수입니다.")).when(pollService).createPoll(Mockito.any(), Mockito.anyLong()); + Mockito.doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID(postId)는 필수입니다.")).when(pollService).createPoll(Mockito.any(), Mockito.anyLong()); assertThatThrownBy(() -> pollService.createPoll(dto, 1L)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("게시글 ID(postId)는 필수입니다."); @@ -174,7 +176,7 @@ void t16() { void t17() { PollCreateDto dto = new PollCreateDto(); dto.setPostId(1L); - Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 제목(voteTitle)은 필수입니다.")).when(pollService).createPoll(Mockito.any(), Mockito.anyLong()); + Mockito.doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 제목(voteTitle)은 필수입니다.")).when(pollService).createPoll(Mockito.any(), Mockito.anyLong()); assertThatThrownBy(() -> pollService.createPoll(dto, 1L)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("투표 제목(voteTitle)은 필수입니다."); diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java index cdc5d978..aa918c93 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java @@ -1,64 +1,102 @@ package com.ai.lawyer.domain.post.controller; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.post.dto.PostDetailDto; +import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; +import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.service.PostService; +import com.ai.lawyer.global.jwt.CookieUtil; +import com.ai.lawyer.global.jwt.TokenProvider; +import com.ai.lawyer.global.oauth.CustomOAuth2UserService; +import com.ai.lawyer.global.oauth.OAuth2FailureHandler; +import com.ai.lawyer.global.oauth.OAuth2SuccessHandler; +import com.ai.lawyer.global.security.SecurityConfig; import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; + import org.mockito.Mockito; + import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; -import com.ai.lawyer.global.security.SecurityConfig; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import jakarta.servlet.http.Cookie; -import static org.mockito.BDDMockito.*; -import com.ai.lawyer.global.jwt.TokenProvider; +import java.util.Collections; import java.util.List; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Import(SecurityConfig.class) @AutoConfigureMockMvc @WebMvcTest( controllers = PostController.class, excludeAutoConfiguration = { - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class, - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration.class, - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class, - org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration.class + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class, + DataSourceAutoConfiguration.class, + JpaBaseConfiguration.class } ) class PostControllerTest { + @Autowired private MockMvc mockMvc; + @MockitoBean private PostService postService; + @MockitoBean - private com.ai.lawyer.domain.member.repositories.MemberRepository memberRepository; + private MemberRepository memberRepository; + @MockitoBean - private com.ai.lawyer.global.jwt.TokenProvider tokenProvider; + private TokenProvider tokenProvider; + @MockitoBean - private com.ai.lawyer.global.jwt.CookieUtil cookieUtil; + private CookieUtil cookieUtil; + @MockitoBean - private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; + private JpaMetamodelMappingContext jpaMappingContext; + @MockitoBean - private org.springframework.data.redis.core.RedisTemplate redisTemplate; + private RedisTemplate redisTemplate; + @MockitoBean - private com.ai.lawyer.global.oauth.CustomOAuth2UserService customOAuth2UserService; + private CustomOAuth2UserService customOAuth2UserService; + @MockitoBean - private com.ai.lawyer.global.oauth.OAuth2SuccessHandler oauth2SuccessHandler; + private OAuth2SuccessHandler oauth2SuccessHandler; + @MockitoBean - private com.ai.lawyer.global.oauth.OAuth2FailureHandler oauth2FailureHandler; + private OAuth2FailureHandler oauth2FailureHandler; + @Autowired private ObjectMapper objectMapper; @@ -75,7 +113,7 @@ void setUp() { @DisplayName("게시글 등록") void t1() throws Exception { PostRequestDto dto = PostRequestDto.builder().postName("테스트 제목").postContent("테스트 내용").build(); - com.ai.lawyer.domain.post.dto.PostDto responseDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").postContent("테스트 내용").build(); + PostDto responseDto = PostDto.builder().postId(1L).postName("테스트 제목").postContent("테스트 내용").build(); Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())).thenReturn(responseDto); mockMvc.perform(post("/api/posts") @@ -83,70 +121,81 @@ void t1() throws Exception { .content(objectMapper.writeValueAsString(dto)) .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(201)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.message").value("게시글이 등록되었습니다.")) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.postName").value("테스트 제목")); + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.message").value("게시글이 등록되었습니다.")) + .andExpect(jsonPath("$.result.postName").value("테스트 제목")); } @Test @DisplayName("게시글 전체 조회") void t2() throws Exception { - List posts = java.util.Collections.emptyList(); - Mockito.when(postService.getAllPosts()).thenReturn(posts); + List posts = Collections.emptyList(); + Mockito.when(postService.getAllPosts(Mockito.anyLong())).thenReturn(posts); mockMvc.perform(get("/api/posts") .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); + .andExpect(jsonPath("$.result").isArray()); } @Test @DisplayName("게시글 단일 조회") void t3() throws Exception { - com.ai.lawyer.domain.post.dto.PostDto postDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build(); - com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDto).build(); - Mockito.when(postService.getPostById(Mockito.anyLong())).thenReturn(postDetailDto); + PostDto postDto = PostDto.builder().postId(1L).postName("테스트 제목").build(); + PostDetailDto postDetailDto = PostDetailDto.builder().post(postDto).build(); + Mockito.when(postService.getPostDetailById(Mockito.anyLong(), Mockito.anyLong())).thenReturn(postDetailDto); mockMvc.perform(get("/api/posts/1") .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.post.postId").value(1L)); + .andExpect(jsonPath("$.result.post.postId").value(1L)); } @Test @DisplayName("회원별 게시글 목록 조회") void t4() throws Exception { - List postDtoList = List.of(com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build()); - com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDtoList.getFirst()).build(); + List postDtoList = List.of(PostDto.builder().postId(1L).postName("테스트 제목").build()); + PostDetailDto postDetailDto = PostDetailDto.builder().post(postDtoList.getFirst()).build(); Mockito.when(postService.getPostsByMemberId(Mockito.anyLong())).thenReturn(postDtoList); - Mockito.when(postService.getPostDetailById(Mockito.anyLong())).thenReturn(postDetailDto); + Mockito.when(postService.getPostDetailById(Mockito.anyLong(), Mockito.anyLong())).thenReturn(postDetailDto); mockMvc.perform(get("/api/posts/member/1") .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); + .andExpect(jsonPath("$.result").isArray()); } @Test @DisplayName("게시글 수정") void t5() throws Exception { - com.ai.lawyer.domain.post.dto.PostDto postDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("수정 제목").build(); - com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDto).build(); + PostDto postDto = PostDto.builder() + .postId(1L) + .postName("수정 제목") + .memberId(1L) + .build(); + PostDetailDto postDetailDto = PostDetailDto.builder().post(postDto).build(); Mockito.doNothing().when(postService).patchUpdatePost(Mockito.anyLong(), Mockito.any()); - Mockito.when(postService.getPostDetailById(Mockito.anyLong())).thenReturn(postDetailDto); - com.ai.lawyer.domain.post.dto.PostUpdateDto updateDto = com.ai.lawyer.domain.post.dto.PostUpdateDto.builder().postName("수정 제목").build(); + Mockito.when(postService.getPostDetailById(eq(1L), Mockito.anyLong())).thenReturn(postDetailDto); + PostUpdateDto updateDto = PostUpdateDto.builder().postName("수정 제목").build(); mockMvc.perform(put("/api/posts/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateDto)) .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.post.postName").value("수정 제목")); + .andExpect(jsonPath("$.result.post.postName").value("수정 제목")); } @Test @DisplayName("게시글 삭제") void t6() throws Exception { + PostDto postDto = PostDto.builder() + .postId(1L) + .postName("삭제 제목") + .memberId(1L) + .build(); + PostDetailDto postDetailDto = PostDetailDto.builder().post(postDto).build(); + Mockito.when(postService.getPostDetailById(eq(1L), Mockito.anyLong())).thenReturn(postDetailDto); Mockito.doNothing().when(postService).deletePost(Mockito.anyLong()); mockMvc.perform(delete("/api/posts/1") @@ -157,22 +206,109 @@ void t6() throws Exception { @Test @DisplayName("게시글 페이징 API") void t7() throws Exception { - java.util.List postList = java.util.List.of( - com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build() - ); - org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); - org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(postList, pageable, 1); - Mockito.when(postService.getPostsPaged(Mockito.any(org.springframework.data.domain.Pageable.class))).thenReturn(page); + List postList = List.of(PostDto.builder().postId(1L).postName("테스트 제목").build()); + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(postList, pageable, 1); + Mockito.when(postService.getPostsPaged(any(Pageable.class), any(Long.class))).thenReturn(page); mockMvc.perform(get("/api/posts/paged") - .param("page", "0") - .param("size", "10") - .cookie(new Cookie("accessToken", "valid-access-token"))) - .andExpect(status().isOk()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.content").isArray()) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.page").value(0)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.size").value(10)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalPages").value(1)) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalElements").value(1)); + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.content").isArray()) + .andExpect(jsonPath("$.result.page").value(0)) + .andExpect(jsonPath("$.result.size").value(10)) + .andExpect(jsonPath("$.result.totalPages").value(1)) + .andExpect(jsonPath("$.result.totalElements").value(1)); + } + + @Test + @DisplayName("게시글 간편 전체 조회") + void t8() throws Exception { + List posts = Collections.emptyList(); + Mockito.when(postService.getAllSimplePosts()).thenReturn(posts); + + mockMvc.perform(get("/api/posts/simplePost") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").isArray()); + } + + @Test + @DisplayName("본인 게시글 단일 조회") + void t9() throws Exception { + PostDto postDto = PostDto.builder().postId(1L).postName("테스트 제목").build(); + Mockito.when(postService.getMyPostById(eq(1L), any(Long.class))).thenReturn(postDto); + + mockMvc.perform(get("/api/posts/my/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.postId").value(1L)); + } + + @Test + @DisplayName("본인 게시글 전체 페이징 조회") + void t10_paged() throws Exception { + Pageable pageable = PageRequest.of(0, 10); + List posts = List.of(PostDto.builder().postId(1L).postName("테스트 제목").build()); + PageImpl page = new PageImpl<>(posts, pageable, 1); + Mockito.when(postService.getMyPostspaged(any(Pageable.class), any(Long.class))).thenReturn(page); + + mockMvc.perform(get("/api/posts/mypaged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.content").isArray()) + .andExpect(jsonPath("$.result.totalElements").value(1)) + .andExpect(jsonPath("$.result.totalPages").value(1)); + } + + @Test + @DisplayName("게시글+투표 동시 등록") + void t11() throws Exception { + PostDetailDto result = PostDetailDto.builder() + .post(PostDto.builder().postId(1L).postName("테스트 제목").build()) + .build(); + PostWithPollCreateDto dto = PostWithPollCreateDto.builder().build(); + Mockito.when(postService.createPostWithPoll(any(PostWithPollCreateDto.class), any(Long.class))).thenReturn(result); + + mockMvc.perform(post("/api/posts/createPost") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto)) + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.post.postId").value(1L)); + } + + @Test + @DisplayName("진행중 투표 게시글 페이징 조회") + void t12() throws Exception { + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(List.of(), pageable, 0); + Mockito.when(postService.getOngoingPostsPaged(any(Pageable.class), any(Long.class))).thenReturn(page); + + mockMvc.perform(get("/api/posts/ongoingPaged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.content").isArray()); + } + + @Test + @DisplayName("마감 투표 게시글 페이징 조회") + void t13() throws Exception { + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(List.of(), pageable, 0); + Mockito.when(postService.getClosedPostsPaged(any(Pageable.class), any(Long.class))).thenReturn(page); + + mockMvc.perform(get("/api/posts/closedPaged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.content").isArray()); } } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostDummyControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostDummyControllerTest.java new file mode 100644 index 00000000..8b263eb5 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostDummyControllerTest.java @@ -0,0 +1,75 @@ +package com.ai.lawyer.domain.post.controller; + +import com.ai.lawyer.domain.post.service.PostDummyService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration; + +@WebMvcTest( + controllers = PostDummyController.class, + excludeAutoConfiguration = { + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class, + DataSourceAutoConfiguration.class, + JpaBaseConfiguration.class + } +) +@AutoConfigureMockMvc(addFilters = false) +class PostDummyControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PostDummyService dummyService; + + @MockitoBean + private JpaMetamodelMappingContext jpaMappingContext; + + @Test + @DisplayName("더미 멤버 생성 API 테스트") + void testCreateDummyMembers() throws Exception { + Mockito.when(dummyService.createDummyMembers(anyInt())).thenReturn(5); + mockMvc.perform(MockMvcRequestBuilders.post("/api/dummy/members") + .param("count", "5") + .contentType(MediaType.APPLICATION_FORM_URLENCODED)) + .andExpect(status().isOk()) + .andExpect(content().string("더미 멤버 5명 생성 완료")); + } + + @Test + @DisplayName("더미 멤버 투표 API 테스트") + void testDummyVote() throws Exception { + Mockito.when(dummyService.dummyVote(anyLong())).thenReturn(3); + mockMvc.perform(MockMvcRequestBuilders.post("/api/dummy/vote") + .param("postId", "1") + .contentType(MediaType.APPLICATION_FORM_URLENCODED)) + .andExpect(status().isOk()) + .andExpect(content().string("더미 멤버 3명 투표 완료")); + } + + @Test + @DisplayName("더미 멤버 삭제 API 테스트") + void testDeleteDummyMembers() throws Exception { + Mockito.when(dummyService.deleteDummyMembers()).thenReturn(2); + mockMvc.perform(MockMvcRequestBuilders.delete("/api/dummy/members")) + .andExpect(status().isOk()) + .andExpect(content().string("더미 멤버 2명 삭제 완료")); + } +} diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java index 0c174b26..0d3eeb02 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java @@ -1,24 +1,27 @@ package com.ai.lawyer.domain.post.service; -import com.ai.lawyer.domain.post.dto.PostRequestDto; -import com.ai.lawyer.domain.post.dto.PostDto; -import com.ai.lawyer.domain.post.dto.PostDetailDto; -import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.*; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; import java.util.Collections; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class PostServiceTest { + @Mock private PostService postService; @@ -32,16 +35,25 @@ void setUp() { void t1() { PostRequestDto dto = new PostRequestDto(); PostDto expected = new PostDto(); - Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())).thenReturn(expected); + + Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())) + .thenReturn(expected); + PostDto result = postService.createPost(dto, 1L); assertThat(result).isEqualTo(expected); } @Test - @DisplayName("회원 정보") + @DisplayName("회원 정보 없음 예외 처리") void t2() { - Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())).thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); - PostRequestDto dto = PostRequestDto.builder().postName("제목").postContent("내용").build(); + Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())) + .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); + + PostRequestDto dto = PostRequestDto.builder() + .postName("제목") + .postContent("내용") + .build(); + assertThatThrownBy(() -> postService.createPost(dto, 2L)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("회원 정보를 찾을 수 없습니다"); @@ -52,25 +64,29 @@ void t2() { void t3() { PostDetailDto expected = new PostDetailDto(); Mockito.when(postService.getPostById(Mockito.anyLong())).thenReturn(expected); + PostDetailDto result = postService.getPostById(1L); assertThat(result).isEqualTo(expected); } @Test - @DisplayName("게시글 상세 조회") + @DisplayName("게시글 상세 조회 (회원 포함)") void t4() { PostDetailDto expected = new PostDetailDto(); - Mockito.when(postService.getPostDetailById(Mockito.anyLong())).thenReturn(expected); - PostDetailDto result = postService.getPostDetailById(1L); + Mockito.when(postService.getPostDetailById(Mockito.anyLong(), Mockito.anyLong())) + .thenReturn(expected); + + PostDetailDto result = postService.getPostDetailById(1L, 1L); assertThat(result).isEqualTo(expected); } @Test @DisplayName("회원 ID로 게시글 목록 조회") void t5() { - java.util.List expected = java.util.Collections.emptyList(); + List expected = Collections.emptyList(); Mockito.when(postService.getPostsByMemberId(Mockito.anyLong())).thenReturn(expected); - java.util.List result = postService.getPostsByMemberId(1L); + + List result = postService.getPostsByMemberId(1L); assertThat(result).isEqualTo(expected); } @@ -79,7 +95,9 @@ void t5() { void t6() { PostDto expected = new PostDto(); PostUpdateDto updateDto = new PostUpdateDto(); + Mockito.when(postService.updatePost(Mockito.anyLong(), Mockito.any())).thenReturn(expected); + PostDto result = postService.updatePost(1L, updateDto); assertThat(result).isEqualTo(expected); } @@ -88,6 +106,7 @@ void t6() { @DisplayName("게시글 삭제") void t7() { Mockito.doNothing().when(postService).deletePost(Mockito.anyLong()); + postService.deletePost(1L); Mockito.verify(postService).deletePost(1L); } @@ -95,9 +114,10 @@ void t7() { @Test @DisplayName("전체 게시글 목록 조회") void t8() { - java.util.List expected = java.util.Collections.emptyList(); - Mockito.when(postService.getAllPosts()).thenReturn(expected); - java.util.List result = postService.getAllPosts(); + List expected = Collections.emptyList(); + Mockito.when(postService.getAllPosts(Mockito.anyLong())).thenReturn(expected); + + List result = postService.getAllPosts(1L); assertThat(result).isEqualTo(expected); } @@ -106,17 +126,24 @@ void t8() { void t9() { PostDto expected = new PostDto(); Mockito.when(postService.getMyPostById(Mockito.anyLong(), Mockito.anyLong())).thenReturn(expected); + PostDto result = postService.getMyPostById(1L, 2L); assertThat(result).isEqualTo(expected); } @Test - @DisplayName("내 게시글 목록 조회") - void t10() { - java.util.List expected = java.util.Collections.emptyList(); - Mockito.when(postService.getMyPosts(Mockito.anyLong())).thenReturn(expected); - java.util.List result = postService.getMyPosts(1L); - assertThat(result).isEqualTo(expected); + @DisplayName("내 게시글 목록 페이징 조회") + void t10_paged() { + List postList = List.of(new PostDto()); + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(postList, pageable, 1); + + Mockito.when(postService.getMyPostspaged(Mockito.eq(pageable), Mockito.eq(1L))).thenReturn(page); + + var result = postService.getMyPostspaged(pageable, 1L); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalPages()).isEqualTo(1); } @Test @@ -124,6 +151,7 @@ void t10() { void t11() { PostUpdateDto updateDto = new PostUpdateDto(); Mockito.doNothing().when(postService).patchUpdatePost(Mockito.anyLong(), Mockito.any()); + postService.patchUpdatePost(1L, updateDto); Mockito.verify(postService).patchUpdatePost(1L, updateDto); } @@ -131,14 +159,15 @@ void t11() { @Test @DisplayName("게시글 페이징 조회") void t12() { - java.util.List postList = java.util.List.of(new PostDto()); - org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); - org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(postList, pageable, 1); - Mockito.when(postService.getPostsPaged(pageable)).thenReturn(page); + List postList = List.of(new PostDto()); + Pageable pageable = PageRequest.of(0, 10); + PageImpl page = new PageImpl<>(postList, pageable, 1); + + Mockito.when(postService.getPostsPaged(Mockito.eq(pageable), Mockito.eq(1L))).thenReturn(page); - var result = postService.getPostsPaged(pageable); + var result = postService.getPostsPaged(pageable, 1L); assertThat(result.getContent()).hasSize(1); assertThat(result.getTotalElements()).isEqualTo(1); assertThat(result.getTotalPages()).isEqualTo(1); } -} +} \ No newline at end of file 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 d5ce7b06..739b6371 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 @@ -70,7 +70,7 @@ void setTokenCookies_Success() { assertThat(accessCookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); assertThat(accessCookieHeader).contains("HttpOnly"); assertThat(accessCookieHeader).contains("Path=/"); - assertThat(accessCookieHeader).contains("Max-Age=300"); // 5분 = 300초 + assertThat(accessCookieHeader).contains("Max-Age=3600"); // 1시간 = 3600초 assertThat(accessCookieHeader).contains("SameSite=Lax"); log.info("액세스 토큰 쿠키 검증 완료: {}", accessCookieHeader); @@ -102,7 +102,7 @@ void setAccessTokenCookie_Success() { String cookieHeader = headerCaptor.getValue(); assertThat(cookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); assertThat(cookieHeader).contains("HttpOnly"); - assertThat(cookieHeader).contains("Max-Age=300"); + assertThat(cookieHeader).contains("Max-Age=3600"); assertThat(cookieHeader).contains("SameSite=Lax"); log.info("=== 액세스 토큰 단독 쿠키 설정 테스트 완료 ==="); } @@ -304,11 +304,11 @@ void cookiePathAttribute_Accessibility() { } @Test - @DisplayName("토큰 만료 시간 확인 - 액세스 5분, 리프레시 7일") + @DisplayName("토큰 만료 시간 확인 - 액세스 1시간, 리프레시 7일") void cookieMaxAgeAttribute_ExpiryTime() { // given log.info("=== 토큰 만료 시간 테스트 시작 ==="); - log.info("액세스 토큰 만료: 5분 (300초)"); + log.info("액세스 토큰 만료: 1시간 (3600초)"); log.info("리프레시 토큰 만료: 7일 (604800초)"); // when @@ -321,8 +321,8 @@ void cookieMaxAgeAttribute_ExpiryTime() { var setCookieHeaders = headerCaptor.getAllValues(); String accessHeader = setCookieHeaders.getFirst(); - assertThat(accessHeader).contains("Max-Age=300"); - log.info("액세스 토큰 만료 시간: 300초 (5분)"); + assertThat(accessHeader).contains("Max-Age=3600"); + log.info("액세스 토큰 만료 시간: 3600초 (1시간)"); String refreshHeader = setCookieHeaders.get(1); assertThat(refreshHeader).contains("Max-Age=604800"); diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index bad18292..66baad09 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -40,6 +40,7 @@ spring: # 로컬 환경: Embedded Redis 사용 data: redis: + embedded: false port: 6370 # 기본 포트와 충돌 방지 ai: diff --git a/infra/.gitignore b/infra/.gitignore index 6d796f56..99d8fcfb 100644 --- a/infra/.gitignore +++ b/infra/.gitignore @@ -5,4 +5,5 @@ terraform.tfstate terraform.tfstate.backup .terraform.tfstate.lock.info secrets.tf -init/sql \ No newline at end of file +init/sql +init/qdrant \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf index 75b2b0b9..8d9fb654 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -165,14 +165,14 @@ resource "aws_s3_object" "init_data_sql" { resource "aws_s3_object" "law_data_sql" { bucket = aws_s3_bucket.s3_bucket_1.bucket - key = "lawData-dev.sql" - source = "${path.module}/init/sql/dev/lawData-dev.sql" + key = "lawData.sql" + source = "${path.module}/init/sql/prod/lawData.sql" } resource "aws_s3_object" "precedent_data_sql" { bucket = aws_s3_bucket.s3_bucket_1.bucket - key = "precedentData-dev.sql" - source = "${path.module}/init/sql/dev/precedentData-dev.sql" + key = "precedentData.sql" + source = "${path.module}/init/sql/prod/precedentData.sql" } # EC2 설정 시작 @@ -311,12 +311,13 @@ docker run -d \ # SQL 폴더 생성 -mkdir -p /home/ec2-user/app/init/sql/dev +mkdir -p /home/ec2-user/app/init/sql/prod # S3에서 SQL 파일 다운로드 aws s3 cp s3://${var.prefix}-s3-bucket-1/init.sql /home/ec2-user/app/init/sql/init.sql -aws s3 cp s3://${var.prefix}-s3-bucket-1/lawData-dev.sql /home/ec2-user/app/init/sql/dev/lawData-dev.sql -aws s3 cp s3://${var.prefix}-s3-bucket-1/precedentData-dev.sql /home/ec2-user/app/init/sql/dev/precedentData-dev.sql +aws s3 cp s3://${var.prefix}-s3-bucket-1/lawData.sql /home/ec2-user/app/init/sql/prod/lawData.sql +aws s3 cp s3://${var.prefix}-s3-bucket-1/precedentData.sql /home/ec2-user/app/init/sql/prod/precedentData.sql +aws s3 cp s3://${var.prefix}-s3-bucket-1/legal_cases.snapshot /home/ec2-user/app/init/qdrant/snapshot/legal_cases.snapshot # MySQL 설정 폴더 생성 및 UTF8 설정 mkdir -p /dockerProjects/mysql/volumes/etc/mysql/conf.d @@ -325,6 +326,13 @@ cat < /dockerProjects/mysql/volumes/etc/mysql/conf.d/charset.cnf [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_general_ci +lower_case_table_names=1 +innodb_buffer_pool_size = 1G +innodb_flush_log_at_trx_commit = 2 +innodb_flush_method = O_DIRECT +tmp_table_size = 256M +max_heap_table_size = 256M +max_connections = 100 [client] default-character-set = utf8mb4 @@ -366,16 +374,21 @@ docker exec mysql mysql -uroot -p${var.password_1} -e " FLUSH PRIVILEGES; " -docker exec -i mysql mysql -uroot -p${var.password_1} ${var.app_1_db_name} < /home/ec2-user/app/init/sql/dev/lawData-dev.sql -docker exec -i mysql mysql -uroot -p${var.password_1} ${var.app_1_db_name} < /home/ec2-user/app/init/sql/dev/precedentData-dev.sql +docker exec -i mysql mysql -uroot -p${var.password_1} ${var.app_1_db_name} < /home/ec2-user/app/init/sql/prod/lawData.sql +docker exec -i mysql mysql -uroot -p${var.password_1} ${var.app_1_db_name} < /home/ec2-user/app/init/sql/prod/precedentData.sql # Qdrant 설치 +mkdir -p /qdrant/snapshots/legal_cases +aws s3 cp s3://${var.prefix}-s3-bucket-1/legal_cases.snapshot /qdrant/snapshots/legal_cases/legal_cases.snapshot + docker run -d \ --name qdrant \ --restart unless-stopped \ --network common \ -p 6333:6333 \ -p 6334:6334 \ + -v /qdrant/storage:/qdrant/storage \ + -v /qdrant/snapshots:/qdrant/snapshots \ qdrant/qdrant # Qdrant healthcheck 대기 @@ -396,6 +409,7 @@ docker run -d \ --network common \ -p 11434:11434 \ -v ollama-data:/root/.ollama \ + --memory 2g \ --entrypoint /bin/sh \ --health-cmd 'curl -f http://localhost:11434/api/version || exit 1' \ --health-interval 10s \ @@ -405,6 +419,27 @@ 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 + + END_OF_FILE } @@ -440,7 +475,7 @@ resource "aws_instance" "ec2_1" { # 사용할 AMI ID ami = data.aws_ami.latest_amazon_linux.id # EC2 인스턴스 유형 - instance_type = "t3.micro" + instance_type = "t3.small" # 사용할 서브넷 ID subnet_id = aws_subnet.subnet_2.id # 적용할 보안 그룹 ID