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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions backend/sql/precedent_fulltext.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE precedent
ADD FULLTEXT idx_precedent_fulltext (notice, summary_of_the_judgment, precedent_content, case_name, case_number);
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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, "이미 투표하셨습니다. 중복 투표는 불가능합니다.");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}

Original file line number Diff line number Diff line change
@@ -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;
}

Original file line number Diff line number Diff line change
@@ -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);
}

Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}