Skip to content

Commit 8730c6d

Browse files
authored
Merge pull request #364 from GarakChoi/develop
Feat[Law] : 통합검색API 추가
2 parents 9c4568a + 0683936 commit 8730c6d

File tree

11 files changed

+282
-3
lines changed

11 files changed

+282
-3
lines changed

backend/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies {
4242
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5'
4343
implementation 'org.springframework.boot:spring-boot-starter-batch'
4444
testImplementation 'org.springframework.kafka:spring-kafka-test'
45+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
4546

4647
// API Documentation (문서화)
4748
implementation 'org.apache.commons:commons-lang3:3.18.0'

backend/sql/precedent_fulltext.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE precedent
2+
ADD FULLTEXT idx_precedent_fulltext (notice, summary_of_the_judgment, precedent_content, case_name, case_number);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.ai.lawyer.domain.poll.event;
2+
3+
import com.ai.lawyer.domain.poll.dto.PollVoteDto;
4+
5+
public class PollVotedEvent {
6+
private final Long pollId;
7+
private final PollVoteDto voteDto;
8+
9+
public PollVotedEvent(Long pollId, PollVoteDto voteDto) {
10+
this.pollId = pollId;
11+
this.voteDto = voteDto;
12+
}
13+
14+
public Long getPollId() {
15+
return pollId;
16+
}
17+
18+
public PollVoteDto getVoteDto() {
19+
return voteDto;
20+
}
21+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.ai.lawyer.domain.poll.event;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.messaging.simp.SimpMessagingTemplate;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.transaction.event.TransactionPhase;
8+
import org.springframework.transaction.event.TransactionalEventListener;
9+
10+
@Component
11+
@RequiredArgsConstructor
12+
@Slf4j
13+
public class PollVotedEventListener {
14+
15+
private final SimpMessagingTemplate messagingTemplate;
16+
17+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
18+
public void onPollVoted(PollVotedEvent event) {
19+
if (event == null || event.getVoteDto() == null) return;
20+
String destination = "/topic/poll." + event.getPollId();
21+
try {
22+
// 이벤트 도착 로그
23+
log.debug("PollVotedEvent received for pollId={}, destination={}", event.getPollId(), destination);
24+
log.trace("Payload: {}", event.getVoteDto());
25+
26+
messagingTemplate.convertAndSend(destination, event.getVoteDto());
27+
28+
// 전송 완료 로그
29+
log.debug("WebSocket message sent to destination={}", destination);
30+
} catch (Exception e) {
31+
log.warn("투표({}) 웹소켓 메시지 전송 실패", event.getPollId(), e);
32+
}
33+
}
34+
}

backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import com.ai.lawyer.global.util.AuthUtil;
2424
import org.springframework.dao.DataIntegrityViolationException;
25+
import org.springframework.context.ApplicationEventPublisher;
26+
import com.ai.lawyer.domain.poll.event.PollVotedEvent;
2527

2628
@Service
2729
@Transactional
@@ -34,6 +36,7 @@ public class PollServiceImpl implements PollService {
3436
private final PollVoteRepository pollVoteRepository;
3537
private final PollStaticsRepository pollStaticsRepository;
3638
private final PostRepository postRepository;
39+
private final ApplicationEventPublisher applicationEventPublisher;
3740

3841
@Override
3942
public PollDto createPoll(PollCreateDto request, Long memberId) {
@@ -120,7 +123,12 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) {
120123
// 1) 기존 투표가 있는지 조회
121124
var existingOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId);
122125
if (existingOpt.isPresent()) {
123-
return handleExistingVote(existingOpt.get(), pollOptions, pollId, pollItemsId, memberId);
126+
PollVoteDto result = handleExistingVote(existingOpt.get(), pollOptions, pollId, pollItemsId, memberId);
127+
// idempotent 응답(같은 항목)인 경우에는 브로드캐스트하지 않음
128+
if (!"이미 해당 항목에 투표하셨습니다.".equals(result.getMessage())) {
129+
applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, result));
130+
}
131+
return result;
124132
}
125133
// 2) 신규 투표 생성
126134
PollVote newVote = PollVote.builder()
@@ -129,13 +137,19 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) {
129137
.memberId(memberId)
130138
.build();
131139
PollVote saved = pollVoteRepository.save(newVote);
132-
return buildPollVote(saved, pollId, pollItemsId, memberId, "투표가 완료되었습니다.");
140+
PollVoteDto dto = buildPollVote(saved, pollId, pollItemsId, memberId, "투표가 완료되었습니다.");
141+
applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, dto));
142+
return dto;
133143
} catch (DataIntegrityViolationException e) {
134144
// 동시성(경합)으로 인해 이미 다른 쓰레드가 투표를 만들어 중복 제약에 걸린 경우 복구 처리
135145
log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e);
136146
var existingAfterOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId);
137147
if (existingAfterOpt.isPresent()) {
138-
return handleExistingVote(existingAfterOpt.get(), pollOptions, pollId, pollItemsId, memberId);
148+
PollVoteDto result = handleExistingVote(existingAfterOpt.get(), pollOptions, pollId, pollItemsId, memberId);
149+
if (!"이미 해당 항목에 투표하셨습니다.".equals(result.getMessage())) {
150+
applicationEventPublisher.publishEvent(new PollVotedEvent(pollId, result));
151+
}
152+
return result;
139153
}
140154
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다.");
141155
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.ai.lawyer.domain.totalSearch.controller;
2+
3+
import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto;
4+
import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto;
5+
import com.ai.lawyer.domain.totalSearch.service.SearchService;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.web.bind.annotation.PostMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
@Slf4j
17+
@RestController
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api/totalSearch")
20+
@Tag(name = "통합 검색", description = "법령 + 판례 통합 검색 API")
21+
public class TotalSearchController {
22+
23+
private final SearchService searchService;
24+
25+
@PostMapping("/search")
26+
@Operation(summary = "법령 + 판례 통합 검색", description = "법령과 판례를 함께 검색합니다.")
27+
public ResponseEntity<?> combinedSearch(@RequestBody SearchRequestDto request) {
28+
try {
29+
SearchResponseDto response = searchService.combinedSearch(request);
30+
return ResponseEntity.ok(response);
31+
} catch (Exception e) {
32+
log.error("통합 검색 에러 : {}", e.getMessage(), e);
33+
return ResponseEntity.badRequest().body("통합 검색 에러 : " + e.getMessage());
34+
}
35+
}
36+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.ai.lawyer.domain.totalSearch.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
9+
@Data
10+
@Builder
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
public class SearchRequestDto {
14+
15+
@Schema(description = "검색 키워드", example = "형사")
16+
private String keyword;
17+
18+
@Schema(description = "페이지 번호 (0부터 시작)", example = "0")
19+
private int pageNumber = 0;
20+
21+
@Schema(description = "페이지 크기", example = "10")
22+
private int pageSize = 10;
23+
24+
@Schema(description = "법령 검색 포함 여부")
25+
private boolean includeLaws = true;
26+
27+
@Schema(description = "판례 검색 포함 여부")
28+
private boolean includePrecedents = true;
29+
}
30+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.ai.lawyer.domain.totalSearch.dto;
2+
3+
import com.ai.lawyer.global.dto.PageResponseDto;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Data;
8+
import lombok.NoArgsConstructor;
9+
10+
@Data
11+
@Builder
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
public class SearchResponseDto {
15+
16+
@Schema(description = "법령 검색 결과 페이지")
17+
private PageResponseDto laws;
18+
19+
@Schema(description = "판례 검색 결과 페이지")
20+
private PageResponseDto precedents;
21+
}
22+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.ai.lawyer.domain.totalSearch.service;
2+
3+
import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto;
4+
import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto;
5+
6+
public interface SearchService {
7+
SearchResponseDto combinedSearch(SearchRequestDto request);
8+
}
9+
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.ai.lawyer.domain.totalSearch.service;
2+
3+
import com.ai.lawyer.domain.law.dto.LawSearchRequestDto;
4+
import com.ai.lawyer.domain.law.service.LawService;
5+
import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto;
6+
import com.ai.lawyer.domain.precedent.service.PrecedentService;
7+
import com.ai.lawyer.domain.totalSearch.dto.SearchRequestDto;
8+
import com.ai.lawyer.domain.totalSearch.dto.SearchResponseDto;
9+
import com.ai.lawyer.global.dto.PageResponseDto;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.stereotype.Service;
13+
14+
import java.util.concurrent.CompletableFuture;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Slf4j
19+
public class SearchServiceImpl implements SearchService {
20+
21+
private final LawService lawService;
22+
private final PrecedentService precedentService;
23+
24+
@Override
25+
public SearchResponseDto combinedSearch(SearchRequestDto request) {
26+
SearchResponseDto response = new SearchResponseDto();
27+
28+
try {
29+
CompletableFuture<?> lawFuture = null;
30+
CompletableFuture<?> precFuture = null;
31+
32+
if (request.isIncludeLaws()) {
33+
LawSearchRequestDto lawReq = LawSearchRequestDto.builder()
34+
.lawName(request.getKeyword())
35+
.pageNumber(request.getPageNumber())
36+
.pageSize(request.getPageSize())
37+
.build();
38+
39+
lawFuture = CompletableFuture.supplyAsync(() -> {
40+
try {
41+
return PageResponseDto.from(lawService.searchLaws(lawReq));
42+
} catch (Exception e) {
43+
log.warn("법령 검색 실패 in combinedSearch: {}", e.getMessage());
44+
return null;
45+
}
46+
});
47+
}
48+
49+
if (request.isIncludePrecedents()) {
50+
PrecedentSearchRequestDto precReq = new PrecedentSearchRequestDto();
51+
precReq.setKeyword(request.getKeyword());
52+
precReq.setPageNumber(request.getPageNumber());
53+
precReq.setPageSize(request.getPageSize());
54+
55+
precFuture = CompletableFuture.supplyAsync(() -> {
56+
try {
57+
return PageResponseDto.from(precedentService.searchByKeywordV2(precReq));
58+
} catch (Exception e) {
59+
log.warn("판례 검색 실패 in combinedSearch: {}", e.getMessage());
60+
return null;
61+
}
62+
});
63+
}
64+
65+
if (lawFuture != null) {
66+
Object lawResult = lawFuture.join();
67+
response.setLaws(lawResult == null ? null : (PageResponseDto) lawResult);
68+
}
69+
70+
if (precFuture != null) {
71+
Object precResult = precFuture.join();
72+
response.setPrecedents(precResult == null ? null : (PageResponseDto) precResult);
73+
}
74+
75+
if (request.isIncludeLaws() && request.isIncludePrecedents()
76+
&& response.getLaws() == null && response.getPrecedents() == null) {
77+
throw new RuntimeException("법령 및 판례 검색 모두 실패");
78+
}
79+
80+
return response;
81+
} catch (Exception e) {
82+
log.error("통합 검색 에러 in service : {}", e.getMessage(), e);
83+
throw e;
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)