diff --git a/backend/build.gradle b/backend/build.gradle index 615add1..08e2cda 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5' implementation 'org.springframework.boot:spring-boot-starter-batch' testImplementation 'org.springframework.kafka:spring-kafka-test' + implementation 'org.springframework.boot:spring-boot-starter-websocket' // API Documentation (문서화) implementation 'org.apache.commons:commons-lang3:3.18.0' diff --git a/backend/sql/precedent_fulltext.sql b/backend/sql/precedent_fulltext.sql new file mode 100644 index 0000000..c65859a --- /dev/null +++ b/backend/sql/precedent_fulltext.sql @@ -0,0 +1,2 @@ +ALTER TABLE precedent + ADD FULLTEXT idx_precedent_fulltext (notice, summary_of_the_judgment, precedent_content, case_name, case_number); \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEvent.java b/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEvent.java new file mode 100644 index 0000000..1646b90 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEvent.java @@ -0,0 +1,21 @@ +package com.ai.lawyer.domain.poll.event; + +import com.ai.lawyer.domain.poll.dto.PollVoteDto; + +public class PollVotedEvent { + private final Long pollId; + private final PollVoteDto voteDto; + + public PollVotedEvent(Long pollId, PollVoteDto voteDto) { + this.pollId = pollId; + this.voteDto = voteDto; + } + + public Long getPollId() { + return pollId; + } + + public PollVoteDto getVoteDto() { + return voteDto; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEventListener.java b/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEventListener.java new file mode 100644 index 0000000..3c0b98b --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/event/PollVotedEventListener.java @@ -0,0 +1,34 @@ +package com.ai.lawyer.domain.poll.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PollVotedEventListener { + + private final SimpMessagingTemplate messagingTemplate; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPollVoted(PollVotedEvent event) { + if (event == null || event.getVoteDto() == null) return; + String destination = "/topic/poll." + event.getPollId(); + try { + // 이벤트 도착 로그 + log.debug("PollVotedEvent received for pollId={}, destination={}", event.getPollId(), destination); + log.trace("Payload: {}", event.getVoteDto()); + + messagingTemplate.convertAndSend(destination, event.getVoteDto()); + + // 전송 완료 로그 + log.debug("WebSocket message sent to destination={}", destination); + } catch (Exception e) { + log.warn("투표({}) 웹소켓 메시지 전송 실패", event.getPollId(), e); + } + } +} \ No newline at end of file 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 1cda1b8..4fc3396 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 @@ -22,6 +22,8 @@ import com.ai.lawyer.global.util.AuthUtil; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.context.ApplicationEventPublisher; +import com.ai.lawyer.domain.poll.event.PollVotedEvent; @Service @Transactional @@ -34,6 +36,7 @@ public class PollServiceImpl implements PollService { private final PollVoteRepository pollVoteRepository; private final PollStaticsRepository pollStaticsRepository; private final PostRepository postRepository; + private final ApplicationEventPublisher applicationEventPublisher; @Override public PollDto createPoll(PollCreateDto request, Long memberId) { @@ -120,7 +123,12 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { // 1) 기존 투표가 있는지 조회 var existingOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); if (existingOpt.isPresent()) { - return handleExistingVote(existingOpt.get(), pollOptions, pollId, pollItemsId, memberId); + PollVoteDto result = handleExistingVote(existingOpt.get(), pollOptions, pollId, pollItemsId, memberId); + // idempotent 응답(같은 항목)인 경우에는 브로드캐스트하지 않음 + if (!"이미 해당 항목에 투표하셨습니다.".equals(result.getMessage())) { + applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, result)); + } + return result; } // 2) 신규 투표 생성 PollVote newVote = PollVote.builder() @@ -129,13 +137,19 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { .memberId(memberId) .build(); PollVote saved = pollVoteRepository.save(newVote); - return buildPollVote(saved, pollId, pollItemsId, memberId, "투표가 완료되었습니다."); + PollVoteDto dto = buildPollVote(saved, pollId, pollItemsId, memberId, "투표가 완료되었습니다."); + applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, dto)); + return dto; } catch (DataIntegrityViolationException e) { // 동시성(경합)으로 인해 이미 다른 쓰레드가 투표를 만들어 중복 제약에 걸린 경우 복구 처리 log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e); var existingAfterOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); if (existingAfterOpt.isPresent()) { - return handleExistingVote(existingAfterOpt.get(), pollOptions, pollId, pollItemsId, memberId); + PollVoteDto result = handleExistingVote(existingAfterOpt.get(), pollOptions, pollId, pollItemsId, memberId); + if (!"이미 해당 항목에 투표하셨습니다.".equals(result.getMessage())) { + applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, result)); + } + return result; } throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다."); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/totalSearch/controller/TotalSearchController.java b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/controller/TotalSearchController.java new file mode 100644 index 0000000..f54ca55 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/controller/TotalSearchController.java @@ -0,0 +1,36 @@ +package com.ai.lawyer.domain.totalSearch.controller; + +import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto; +import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto; +import com.ai.lawyer.domain.totalSearch.service.SearchService; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/totalSearch") +@Tag(name = "통합 검색", description = "법령 + 판례 통합 검색 API") +public class TotalSearchController { + + private final SearchService searchService; + + @PostMapping("/search") + @Operation(summary = "법령 + 판례 통합 검색", description = "법령과 판례를 함께 검색합니다.") + public ResponseEntity combinedSearch(@RequestBody SearchRequestDto request) { + try { + SearchResponseDto response = searchService.combinedSearch(request); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("통합 검색 에러 : {}", e.getMessage(), e); + return ResponseEntity.badRequest().body("통합 검색 에러 : " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchRequestDto.java new file mode 100644 index 0000000..efc66c3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchRequestDto.java @@ -0,0 +1,30 @@ +package com.ai.lawyer.domain.totalSearch.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchRequestDto { + + @Schema(description = "검색 키워드", example = "형사") + private String keyword; + + @Schema(description = "페이지 번호 (0부터 시작)", example = "0") + private int pageNumber = 0; + + @Schema(description = "페이지 크기", example = "10") + private int pageSize = 10; + + @Schema(description = "법령 검색 포함 여부") + private boolean includeLaws = true; + + @Schema(description = "판례 검색 포함 여부") + private boolean includePrecedents = true; +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchResponseDto.java b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchResponseDto.java new file mode 100644 index 0000000..8c91ded --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/dto/SearchResponseDto.java @@ -0,0 +1,22 @@ +package com.ai.lawyer.domain.totalSearch.dto; + +import com.ai.lawyer.global.dto.PageResponseDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchResponseDto { + + @Schema(description = "법령 검색 결과 페이지") + private PageResponseDto laws; + + @Schema(description = "판례 검색 결과 페이지") + private PageResponseDto precedents; +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchService.java b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchService.java new file mode 100644 index 0000000..61b2427 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchService.java @@ -0,0 +1,9 @@ +package com.ai.lawyer.domain.totalSearch.service; + +import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto; +import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto; + +public interface SearchService { + SearchResponseDto combinedSearch(SearchRequestDto request); +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchServiceImpl.java new file mode 100644 index 0000000..9320d54 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/totalSearch/service/SearchServiceImpl.java @@ -0,0 +1,86 @@ +package com.ai.lawyer.domain.totalSearch.service; + +import com.ai.lawyer.domain.law.dto.LawSearchRequestDto; +import com.ai.lawyer.domain.law.service.LawService; +import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto; +import com.ai.lawyer.domain.precedent.service.PrecedentService; +import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto; +import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto; +import com.ai.lawyer.global.dto.PageResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SearchServiceImpl implements SearchService { + + private final LawService lawService; + private final PrecedentService precedentService; + + @Override + public SearchResponseDto combinedSearch(SearchRequestDto request) { + SearchResponseDto response = new SearchResponseDto(); + + try { + CompletableFuture lawFuture = null; + CompletableFuture precFuture = null; + + if (request.isIncludeLaws()) { + LawSearchRequestDto lawReq = LawSearchRequestDto.builder() + .lawName(request.getKeyword()) + .pageNumber(request.getPageNumber()) + .pageSize(request.getPageSize()) + .build(); + + lawFuture = CompletableFuture.supplyAsync(() -> { + try { + return PageResponseDto.from(lawService.searchLaws(lawReq)); + } catch (Exception e) { + log.warn("법령 검색 실패 in combinedSearch: {}", e.getMessage()); + return null; + } + }); + } + + if (request.isIncludePrecedents()) { + PrecedentSearchRequestDto precReq = new PrecedentSearchRequestDto(); + precReq.setKeyword(request.getKeyword()); + precReq.setPageNumber(request.getPageNumber()); + precReq.setPageSize(request.getPageSize()); + + precFuture = CompletableFuture.supplyAsync(() -> { + try { + return PageResponseDto.from(precedentService.searchByKeywordV2(precReq)); + } catch (Exception e) { + log.warn("판례 검색 실패 in combinedSearch: {}", e.getMessage()); + return null; + } + }); + } + + if (lawFuture != null) { + Object lawResult = lawFuture.join(); + response.setLaws(lawResult == null ? null : (PageResponseDto) lawResult); + } + + if (precFuture != null) { + Object precResult = precFuture.join(); + response.setPrecedents(precResult == null ? null : (PageResponseDto) precResult); + } + + if (request.isIncludeLaws() && request.isIncludePrecedents() + && response.getLaws() == null && response.getPrecedents() == null) { + throw new RuntimeException("법령 및 판례 검색 모두 실패"); + } + + return response; + } catch (Exception e) { + log.error("통합 검색 에러 in service : {}", e.getMessage(), e); + throw e; + } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/config/WebSocketConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/WebSocketConfig.java new file mode 100644 index 0000000..327919c --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.ai.lawyer.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS(); + } +} +