diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index acd402aa..4bbc891c 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -152,7 +152,7 @@ jobs: if: github.ref == 'refs/heads/main' # ✅ main 브랜치일 때만 실행 env: REGISTRY: ghcr.io - IMAGE_PREFIX: ${{ github.repository_owner }} + IMAGE_PREFIX: ${{ github.repository }} steps: - uses: actions/checkout@v4 @@ -227,14 +227,14 @@ jobs: EOF # EC2에서 GHCR 로그인 - echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u doohyojeong --password-stdin + echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin # 최신 이미지 pull & 컨테이너 실행 - docker pull ghcr.io/doohyojeong/${{ env.DOCKER_IMAGE_NAME }}:latest + docker pull ghcr.io/prgrms-web-devcourse-final-project/${{ env.DOCKER_IMAGE_NAME }}:latest docker stop app1 2>/dev/null docker rm app1 2>/dev/null docker run --env-file /home/ec2-user/prod.env -d --name app1 -p 8080:8080 \ - ghcr.io/doohyojeong/${{ env.DOCKER_IMAGE_NAME }}:latest + ghcr.io/prgrms-web-devcourse-final-project/${{ env.DOCKER_IMAGE_NAME }}:latest # dangling image 정리 + .env 삭제 docker rmi $(docker images -f "dangling=true" -q) diff --git a/backend/build.gradle b/backend/build.gradle index 9c7b18a0..76215533 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -2,6 +2,9 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.5' id 'io.spring.dependency-management' version '1.1.7' + + id("org.jetbrains.kotlin.jvm") version "1.9.25" + id("com.google.devtools.ksp") version "1.9.25-1.0.20" } group = 'com.ai.lawyer' @@ -57,6 +60,10 @@ dependencies { // Annotation Processing (어노테이션 처리) annotationProcessor 'org.projectlombok:lombok' + // Querydsl + implementation("io.github.openfeign.querydsl:querydsl-jpa:7.0") + ksp("io.github.openfeign.querydsl:querydsl-ksp-codegen:7.0") + // Testing (테스트) testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' 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 new file mode 100644 index 00000000..7e5af276 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/controller/LawController.java @@ -0,0 +1,67 @@ +package com.ai.lawyer.domain.law.controller; + +import com.ai.lawyer.domain.law.dto.LawSearchRequestDto; +import com.ai.lawyer.domain.law.dto.LawsDto; +import com.ai.lawyer.domain.law.entity.Law; +import com.ai.lawyer.domain.law.service.LawService; +import com.ai.lawyer.global.dto.PageResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/law") +public class LawController { + + private final LawService lawService; + + // 법령 리스트 출력 + @GetMapping(value = "/list") + public ResponseEntity list( + @RequestParam String query, + @RequestParam int page + ) throws Exception { + String lawList = lawService.getLawList(query, page); + return ResponseEntity.ok().body(lawList); + } + + + @GetMapping(value = "/list/save") + public ResponseEntity getStatisticsCard( + @RequestParam String query, + @RequestParam int page + ) throws Exception { + long startTime = System.currentTimeMillis(); + + lawService.saveLaw(query, page); + + long endTime = System.currentTimeMillis(); + long elapsedTime = endTime - startTime; + System.out.println("saveLaw 실행 시간: " + elapsedTime + "ms"); + + return ResponseEntity.ok().body("Success"); + } + + @GetMapping("/{id}") + public ResponseEntity getFullLaw(@PathVariable Long id) { + Law law = lawService.getLawWithAllChildren(id); + + return ResponseEntity.ok(law); + } + + + @PostMapping("/search") + 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); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java new file mode 100644 index 00000000..a7aab0d9 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java @@ -0,0 +1,20 @@ +package com.ai.lawyer.domain.law.dto; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Builder +public class LawSearchRequestDto { + private String lawName; // 법령명 + private String lawField; // 법령분야 + private String ministry; // 소관부처 + private LocalDate promulgationDateStart; // 공포일자 시작 + private LocalDate promulgationDateEnd; // 공포일자 종료 + private LocalDate enforcementDateStart; // 시행일자 시작 + private LocalDate enforcementDateEnd; // 시행일자 종료 + private int pageNumber; // 페이지 번호 + private int pageSize; // 페이지 크기 +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java new file mode 100644 index 00000000..853b5785 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java @@ -0,0 +1,26 @@ +package com.ai.lawyer.domain.law.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Builder +@AllArgsConstructor +public class LawsDto { + private Long id; + + private String lawName; // 법령명 + + private String lawField; // 법령분야 + + private String ministry; // 소관부처 + + private String promulgationNumber; // 공포번호 + + private LocalDate promulgationDate; // 공포일자 + + private LocalDate enforcementDate; // 시행일자 +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java new file mode 100644 index 00000000..e91430ab --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.entity.Hang; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface HangRepository extends JpaRepository { + + // Hang + Ho만 페치 + @EntityGraph(attributePaths = "hoList") + List findByJoId(Long joId); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java new file mode 100644 index 00000000..fb810632 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java @@ -0,0 +1,9 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.entity.Ho; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface HoRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java new file mode 100644 index 00000000..6d8f2b56 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java @@ -0,0 +1,15 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.entity.Jang; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface JangRepository extends JpaRepository { + // Jang + Jo만 페치 + @EntityGraph(attributePaths = "joList") + List findByLawId(Long lawId); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java new file mode 100644 index 00000000..a0baa07f --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.entity.Jo; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface JoRepository extends JpaRepository { + + // Jo + Hang만 페치 + @EntityGraph(attributePaths = "hangList") + List findByJangId(Long jangId); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepository.java new file mode 100644 index 00000000..cd1fae35 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepository.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.entity.Law; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface LawRepository extends JpaRepository, LawRepositoryCustom { + + // Law + Jang만 페치 + @EntityGraph(attributePaths = "jangList") + Optional findWithJangById(Long id); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustom.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustom.java new file mode 100644 index 00000000..9f4f20e0 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.dto.LawSearchRequestDto; +import com.ai.lawyer.domain.law.dto.LawsDto; +import org.springframework.data.domain.Page; + +public interface LawRepositoryCustom { + Page searchLaws(LawSearchRequestDto searchRequest); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustomImpl.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustomImpl.java new file mode 100644 index 00000000..491517be --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/LawRepositoryCustomImpl.java @@ -0,0 +1,101 @@ +package com.ai.lawyer.domain.law.repository; + +import com.ai.lawyer.domain.law.dto.LawSearchRequestDto; +import com.ai.lawyer.domain.law.dto.LawsDto; +import com.ai.lawyer.domain.law.entity.QLaw; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class LawRepositoryCustomImpl implements LawRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + private QLaw law = QLaw.law; + + @Override + public Page searchLaws(LawSearchRequestDto searchRequest) { + BooleanBuilder builder = new BooleanBuilder(); + + // 법령명 조건 (부분 검색) + if (StringUtils.hasText(searchRequest.getLawName())) { + builder.and(law.getLawName().containsIgnoreCase(searchRequest.getLawName())); + } + + // 소관부처 조건 (완전 일치) + if (StringUtils.hasText(searchRequest.getMinistry())) { + builder.and(law.getMinistry().eq(searchRequest.getMinistry())); + } + + // 법령분야 조건 (완전 일치) + if (StringUtils.hasText(searchRequest.getLawField())) { + builder.and(law.getLawField().eq(searchRequest.getLawField())); + } + + // 시행일자 범위 조건 + if (searchRequest.getEnforcementDateStart() != null && + searchRequest.getEnforcementDateEnd() != null) { + builder.and(law.getEnforcementDate().between( + searchRequest.getEnforcementDateStart(), + searchRequest.getEnforcementDateEnd())); + } else if (searchRequest.getEnforcementDateStart() != null) { + builder.and(law.getEnforcementDate().goe(searchRequest.getEnforcementDateStart())); + } else if (searchRequest.getEnforcementDateEnd() != null) { + builder.and(law.getEnforcementDate().loe(searchRequest.getEnforcementDateEnd())); + } + + // 공포일자 범위 조건 + if (searchRequest.getPromulgationDateStart() != null && + searchRequest.getPromulgationDateEnd() != null) { + builder.and(law.getPromulgationDate().between( + searchRequest.getPromulgationDateStart(), + searchRequest.getPromulgationDateEnd())); + } else if (searchRequest.getPromulgationDateStart() != null) { + builder.and(law.getPromulgationDate().goe(searchRequest.getPromulgationDateStart())); + } else if (searchRequest.getPromulgationDateEnd() != null) { + builder.and(law.getPromulgationDate().loe(searchRequest.getPromulgationDateEnd())); + } + + Pageable pageable = PageRequest.of(searchRequest.getPageNumber(), searchRequest.getPageSize()); + + // DTO 프로젝션 조회 + JPAQuery query = queryFactory + .select(Projections.constructor( + LawsDto.class, + law.getId(), + law.getLawName(), + law.getLawField(), + law.getMinistry(), + law.getPromulgationNumber(), + law.getPromulgationDate(), + law.getEnforcementDate() + )) + .from(law) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + List content = query.fetch(); + + // 전체 개수 조회 + Long total = queryFactory + .select(law.count()) + .from(law) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/service/LawService.java b/backend/src/main/java/com/ai/lawyer/domain/law/service/LawService.java new file mode 100644 index 00000000..ffe3f63c --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/law/service/LawService.java @@ -0,0 +1,265 @@ +package com.ai.lawyer.domain.law.service; + + +import com.ai.lawyer.domain.law.dto.LawSearchRequestDto; +import com.ai.lawyer.domain.law.dto.LawsDto; +import com.ai.lawyer.domain.law.entity.*; +import com.ai.lawyer.domain.law.repository.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityNotFoundException; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Service +@AllArgsConstructor +public class LawService { + + private LawRepository lawRepository; + private JangRepository jangRepository; + private JoRepository joRepository; + private HangRepository hangRepository; + private HoRepository hoRepository; + + private final String BASE_URL = "http://www.law.go.kr/DRF"; + private final String OC = "noheechul"; // 실제 OC로 변경 필요 + private final ObjectMapper objectMapper = new ObjectMapper(); + + + /** + * 법령 검색 + * @param searchRequest + * @return Page + */ + public Page searchLaws(LawSearchRequestDto searchRequest) { + return lawRepository.searchLaws(searchRequest); + } + + /** + * 법령 ID로 법령과 모든 하위 엔티티를 조회 + * @param lawId + * @return Law (Jang, Jo, Hang, Ho 모두 포함) + */ + public Law getLawWithAllChildren(Long lawId) { + Law law = lawRepository.findWithJangById(lawId) + .orElseThrow(() -> new EntityNotFoundException("법령이 없습니다: " + lawId)); + + // 2) 각 JangEntity에 JoEntity 세팅 + List jangs = jangRepository.findByLawId(lawId); + law.setJangList(jangs); + + for (Jang jang : jangs) { + Long jangId = jang.getId(); + List jos = joRepository.findByJangId(jangId); + jang.setJoList(jos); + + for (Jo jo : jos) { + Long joId = jo.getId(); + List hangs = hangRepository.findByJoId(joId); + jo.setHangList(hangs); + + for (Hang hang : hangs) { + // 호는 기본 지연 로딩으로 필요 시 조회 + hang.getHoList().size(); + } + } + } + + return law; + } + + /** + * open api 법령 리스트 조회 → 법령 ID 추출 → 상세 법령 정보 조회 → 파싱 → DB 저장 + */ + public void saveLaw(String query, int page) throws Exception { + String lawList = getLawList(query, page); + + List lawIds = getLawListIds(lawList); + + for (String lawId : lawIds) { + String lawJson = getLawJson(lawId); + try { + saveLaw(lawJson); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * open api 법령 리스트 조회 (JSON 문자열 반환) + */ + public String getLawList(String query, int page) throws Exception { + String url = UriComponentsBuilder.fromHttpUrl(BASE_URL + "/lawSearch.do") + .queryParam("OC", OC) + .queryParam("target", "law") + .queryParam("type", "JSON") + .queryParam("query", query) + .queryParam("page", page) + .queryParam("display", 100) + .build() + .toUriString(); + + RestTemplate restTemplate = new RestTemplate(); + String json = restTemplate.getForObject(url, String.class); + return json; + } + + /** + * 법령 리스트 JSON에서 법령 ID 추출 + */ + private List getLawListIds(String json) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(json); + List idList = new ArrayList<>(); + + JsonNode lawSearchNode = root.path("LawSearch"); + JsonNode lawArray = lawSearchNode.path("law"); + + JsonNode lawNode = lawSearchNode.path("law"); + + // law가 배열일 때 + if (lawNode.isArray()) { + for (JsonNode item : lawNode) { + String lawId = item.path("법령ID").asText(); + if (StringUtils.hasText(lawId)) { + idList.add(lawId); + } + } + } + // law가 단일 객체일 때 + else if (lawNode.isObject()) { + String lawId = lawNode.path("법령ID").asText(); + if (StringUtils.hasText(lawId)) { + idList.add(lawId); + } + } + + return idList; + } + + /** + * 법령 ID로 상세 법령 정보 조회 (JSON 문자열 반환) + */ + private String getLawJson(String lawId) { + String url = UriComponentsBuilder.fromHttpUrl(BASE_URL+ "/lawService.do") + .queryParam("OC", OC) + .queryParam("target", "law") + .queryParam("ID", lawId) + .queryParam("type", "JSON") + .build() + .toUriString(); + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(url, String.class); + } + + /** + * JSON 문자열을 파싱해 Law → Jang → Jo → Hang → Ho 계층으로 저장 + */ + private void saveLaw(String json) throws IOException { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + + + JsonNode root = objectMapper.readTree(json); + JsonNode basic = root.path("법령").path("기본정보"); + + // 1. Law 저장 + Law law = new Law(); + law.setLawName(basic.path("법령명_한글").asText()); + law.setLawField(basic.path("법종구분").path("content").asText()); + law.setMinistry(basic.path("소관부처").path("content").asText()); + law.setPromulgationNumber(basic.path("공포번호").asText()); + law.setPromulgationDate(LocalDate.parse(basic.path("공포일자").asText(), formatter)); + law.setEnforcementDate(LocalDate.parse(basic.path("시행일자").asText(), formatter)); + law = lawRepository.save(law); + + // 2. 조문단위 순회 + JsonNode articles = root.path("법령").path("조문").path("조문단위"); + + Jang jang = new Jang(); + jang.setContent(null); + jang.setLaw(law); + + if (articles.isArray()) { + for (JsonNode art : articles) { + String key = art.path("조문키").asText(); + String content = art.path("조문내용").asText(); + + // JangEntity: key 끝자리 0 + if (key.endsWith("0")) { + jang = new Jang(); + jang.setContent(content); + jang.setLaw(law); + jang = jangRepository.save(jang); + }else { + if(jang.getContent()==null){ + jang = jangRepository.save(jang); + } + Jo jo = new Jo(); + jo.setContent(content); + jo.setJang(jang); + jo = joRepository.save(jo); + + // HangEntity + JsonNode paragraphs = art.path("항"); + + if (!paragraphs.isMissingNode()) { + // "항"이 배열인 경우 + if (paragraphs.isArray()) { + for (JsonNode p : paragraphs) { + Hang hang = new Hang(); + // "항내용"이 없으면 null, 있으면 해당 텍스트 저장 + String hangContent = p.path("항내용").isMissingNode() + ? null + : p.path("항내용").asText(); + hang.setContent(hangContent); + hang.setJo(jo); + hang = hangRepository.save(hang); + + // HoEntity 처리 + processHoEntities(p.path("호"), hang); + } + } + // "항"이 객체인 경우 (항내용 없이 바로 호 배열) + else if (paragraphs.isObject()) { + Hang hang = new Hang(); + hang.setContent(null); // 항내용이 없으므로 null + hang.setJo(jo); + hang = hangRepository.save(hang); + + // 객체 내의 "호" 배열 처리 + processHoEntities(paragraphs.path("호"), hang); + } + } + } + + } + } + } + + // HoEntity 처리 메서드 + private void processHoEntities(JsonNode itemsNode, Hang hang) { + if (itemsNode.isArray()) { + for (JsonNode h : itemsNode) { + Ho ho = new Ho(); + ho.setContent(h.path("호내용").asText()); + ho.setHang(hang); + hoRepository.save(ho); + } + } + } + + +} + 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 new file mode 100644 index 00000000..259ddb11 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/controller/PrecedentController.java @@ -0,0 +1,53 @@ +package com.ai.lawyer.domain.precedent.controller; + +import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto; +import com.ai.lawyer.domain.precedent.dto.PrecedentSummaryListDto; +import com.ai.lawyer.domain.precedent.entity.Precedent; +import com.ai.lawyer.domain.precedent.service.PrecedentService; +import com.ai.lawyer.global.dto.PageResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/precedent") +public class PrecedentController { + + private final PrecedentService precedentService; + + @GetMapping(value = "/list/save") + public ResponseEntity list( + @RequestParam String query + ) throws Exception { + return ResponseEntity.ok().body(precedentService.searchAndSaveAll(query)); + } + + @PostMapping("/search") + 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); + } + + /** + * GET /api/precedent/{id} + * 주어진 id로 Precedent 조회 + * + * @param id Precedent PK + */ + @GetMapping("/{id}") + public ResponseEntity getPrecedent(@PathVariable Long id) { + Precedent precedent = precedentService.getPrecedentById(id); + return ResponseEntity.ok(precedent); + } +} 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 new file mode 100644 index 00000000..c07398fc --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java @@ -0,0 +1,10 @@ +package com.ai.lawyer.domain.precedent.dto; + +import lombok.Data; + +@Data +public class PrecedentSearchRequestDto { + private String keyword; // 검색 키워드 + private int pageNumber; // 페이지 번호 + private int pageSize; // 페이지 크기 +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSummaryListDto.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSummaryListDto.java new file mode 100644 index 00000000..d07169c7 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSummaryListDto.java @@ -0,0 +1,15 @@ +package com.ai.lawyer.domain.precedent.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@AllArgsConstructor +public class PrecedentSummaryListDto { + private Long id; + private String caseName; // 사건명 + private String caseNumber; // 사건번호 + private LocalDate sentencingDate; // 선고일자 +} 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 new file mode 100644 index 00000000..95a090f3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepository.java @@ -0,0 +1,14 @@ +package com.ai.lawyer.domain.precedent.repository; + +import com.ai.lawyer.domain.precedent.entity.Precedent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PrecedentRepository extends JpaRepository, PrecedentRepositoryCustom { + + /** + * 판례일련번호로 존재 여부 확인 + */ + boolean existsByPrecedentNumber(String precedentNumber); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryCustom.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryCustom.java new file mode 100644 index 00000000..3f49385e --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ai.lawyer.domain.precedent.repository; + +import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto; +import com.ai.lawyer.domain.precedent.dto.PrecedentSummaryListDto; +import org.springframework.data.domain.Page; + + +public interface PrecedentRepositoryCustom { + Page searchPrecedentsByKeyword(PrecedentSearchRequestDto requestDto); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java new file mode 100644 index 00000000..31177cd3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.ai.lawyer.domain.precedent.repository; + +import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto; +import com.ai.lawyer.domain.precedent.dto.PrecedentSummaryListDto; +import com.ai.lawyer.domain.precedent.entity.QPrecedent; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PrecedentRepositoryImpl implements PrecedentRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + private final QPrecedent precedent = QPrecedent.precedent; + + @Override + public Page searchPrecedentsByKeyword(PrecedentSearchRequestDto requestDto) { + + BooleanBuilder builder = new BooleanBuilder(); + + if (StringUtils.hasText(requestDto.getKeyword())) { + String pattern = "%" + requestDto.getKeyword().trim() + "%"; + builder.or(precedent.getNotice().like(pattern)) + .or(precedent.getSummaryOfTheJudgment().like(pattern)) + .or(precedent.getPrecedentContent().like(pattern)) + .or(precedent.getCaseName().like(pattern)) + .or(precedent.getCaseNumber().like(pattern)); + } + + // 페이징 및 정렬 설정 + Pageable pageable = PageRequest.of( + requestDto.getPageNumber(), + requestDto.getPageSize(), + Sort.by(Sort.Direction.DESC, "sentencingDate") + ); + + // 1) 전체 건수 조회 + long total = queryFactory + .selectFrom(precedent) + .where(builder) + .fetchCount(); + + if (total == 0) { + return new PageImpl<>(Collections.emptyList(), pageable, 0); + } + + // 2) 데이터 조회 + List content = queryFactory + .select(Projections.constructor(PrecedentSummaryListDto.class, + precedent.getId(), + precedent.getCaseName(), + precedent.getCaseNumber(), + precedent.getSentencingDate())) + .from(precedent) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(precedent.getSentencingDate().desc()) + .fetch(); + + return new PageImpl<>(content, pageable, total); + } +} 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 new file mode 100644 index 00000000..cb661678 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/service/PrecedentService.java @@ -0,0 +1,258 @@ +package com.ai.lawyer.domain.precedent.service; + +import com.ai.lawyer.domain.precedent.dto.PrecedentSearchRequestDto; +import com.ai.lawyer.domain.precedent.dto.PrecedentSummaryListDto; +import com.ai.lawyer.domain.precedent.entity.Precedent; +import com.ai.lawyer.domain.precedent.repository.PrecedentRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@AllArgsConstructor +public class PrecedentService { + + private final PrecedentRepository precedentRepository; + + private final String BASE_URL = "http://www.law.go.kr/DRF"; + private final String OC = "noheechul"; // 실제 OC로 변경 필요 + private final ObjectMapper objectMapper = new ObjectMapper(); + + + /** + * 주어진 id로 Precedent 조회 + * + * @param id Precedent PK + * @return Precedent 엔티티 + * @throws NoSuchElementException id에 해당하는 Precedent가 없으면 예외 발생 + */ + public Precedent getPrecedentById(Long id) { + return precedentRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Precedent not found for id: " + id)); + } + + /** + * 키워드로 판례 검색 (판시사항, 판결요지, 판례내용에서 검색) + * + * @param requestDto 검색 조건 DTO + * @return 사건명, 사건번호, 선고일자 리스트 + */ + public Page searchByKeyword(PrecedentSearchRequestDto requestDto) { + return precedentRepository.searchPrecedentsByKeyword(requestDto); + } + + /** + * 1. 특정 키워드로 판례 일련번호 리스트 가져오는 메서드 + * + * @param query 검색 키워드 + * @return 판례일련번호 리스트 + * @throws Exception JSON 파싱 오류 시 예외 발생 + */ + public List getPrecedentNumbers(String query) throws Exception { + RestTemplate restTemplate = new RestTemplate(); + List precedentNumbers = new ArrayList<>(); + + int page = 1; + int display = 100; // 한 페이지당 최대 조회 건수 + int totalCnt; + + do { + // 페이지별로 API 호출 URL 생성 + String url = UriComponentsBuilder.fromHttpUrl(BASE_URL + "/lawSearch.do") + .queryParam("OC", OC) + .queryParam("target", "prec") + .queryParam("type", "JSON") + .queryParam("display", display) + .queryParam("page", page) + .queryParam("query", query) + .build() + .toUriString(); + + String json = restTemplate.getForObject(url, String.class); + JsonNode root = objectMapper.readTree(json); + JsonNode precSearch = root.path("PrecSearch"); + + // totalCnt 추출 + totalCnt = precSearch.path("totalCnt").asInt(0); + if (totalCnt == 0) { + return Collections.emptyList(); + } + + JsonNode precArray = precSearch.path("prec"); + if (precArray.isArray()) { + precArray.forEach(item -> { + String number = item.path("판례일련번호").asText(); + if (StringUtils.hasText(number)) { + precedentNumbers.add(number); + } + }); + } else if (precArray.isObject()) { + String number = precArray.path("판례일련번호").asText(); + if (StringUtils.hasText(number)) { + precedentNumbers.add(number); + } + } + + page++; + } while ((page - 1) * display < totalCnt); + + return precedentNumbers; + } + + /** + * 2. 일련번호 리스트로 상세 조회하는 메서드 + * + * @param precedentIds 판례일련번호 리스트 + * @return 조회된 Precedent 객체 리스트 + * @throws Exception API 호출 또는 JSON 파싱 오류 시 예외 발생 + */ + public List getPrecedentDetails(List precedentIds) throws Exception { + List precedents = new ArrayList<>(); + + for (String precedentId : precedentIds) { + try { + // 단일 판례 상세 정보 조회 + String url = UriComponentsBuilder.fromHttpUrl(BASE_URL + "/lawService.do") + .queryParam("OC", OC) + .queryParam("target", "prec") + .queryParam("ID", precedentId) + .queryParam("type", "JSON") + .build() + .toUriString(); + + RestTemplate restTemplate = new RestTemplate(); + String json = restTemplate.getForObject(url, String.class); + + // JSON → Precedent 엔티티 변환 + Precedent precedent = parseJsonToPrecedent(json); + if (precedent != null) { + precedents.add(precedent); + } + + // API 호출 간격 조절 + Thread.sleep(100); + + } catch (Exception e) { + System.err.println("Error fetching precedent " + precedentId + ": " + e.getMessage()); + // 개별 오류는 무시하고 계속 진행 + } + } + + return precedents; + } + + /** + * 3. 상세 조회한 판례 저장하는 메서드 + * + * @param precedents 저장할 Precedent 객체 리스트 + * @return 저장된 Precedent 객체 리스트 + */ + public List savePrecedents(List precedents) { + List savedPrecedents = new ArrayList<>(); + + for (Precedent precedent : precedents) { + try { + // 중복 확인 (판례일련번호 기준) + if (!precedentRepository.existsByPrecedentNumber(precedent.getPrecedentNumber())) { + Precedent saved = precedentRepository.save(precedent); + savedPrecedents.add(saved); + System.out.println("Saved precedent: " + precedent.getPrecedentNumber()); + } else { + System.out.println("Already exists: " + precedent.getPrecedentNumber()); + } + } catch (Exception e) { + System.err.println("Error saving precedent " + precedent.getPrecedentNumber() + ": " + e.getMessage()); + } + } + + return savedPrecedents; + } + + /** + * JSON을 Precedent 엔티티로 변환하는 헬퍼 메서드 + * + * @param json API 응답 JSON 문자열 + * @return 변환된 Precedent 객체 (실패 시 null) + * @throws Exception JSON 파싱 오류 시 예외 발생 + */ + private Precedent parseJsonToPrecedent(String json) throws Exception { + JsonNode root = objectMapper.readTree(json); + JsonNode precService = root.path("PrecService"); + + if (precService.isMissingNode()) { + return null; + } + + Precedent precedent = new Precedent(); + + // 기본 정보 매핑 + precedent.setPrecedentNumber(precService.path("판례정보일련번호").asText("")); + precedent.setCaseName(precService.path("사건명").asText("")); + precedent.setCaseNumber(precService.path("사건번호").asText("")); + + // 선고일자 변환 (yyyyMMdd → LocalDate) + String dateStr = precService.path("선고일자").asText(""); + if (StringUtils.hasText(dateStr) && dateStr.length() == 8) { + try { + precedent.setSentencingDate(LocalDate.parse(dateStr, + DateTimeFormatter.ofPattern("yyyyMMdd"))); + } catch (Exception e) { + System.err.println("Date parsing error for: " + dateStr); + } + } + + // 나머지 필드 매핑 + precedent.setSentence(precService.path("선고").asText("")); + precedent.setCourtName(precService.path("법원명").asText("")); + precedent.setCourtTypeCode(precService.path("법원종류코드").asText("")); + precedent.setCaseTypeName(precService.path("사건종류명").asText("")); + precedent.setCaseTypeCode(precService.path("사건종류코드").asText("")); + precedent.setTypeOfJudgment(precService.path("판결유형").asText("")); + precedent.setReferencePrecedent(precService.path("참조판례").asText("")); + + // 판례 내용 (원본 그대로 저장) + precedent.setNotice(precService.path("판시사항").asText("")); + precedent.setSummaryOfTheJudgment(precService.path("판결요지").asText("")); + precedent.setReferenceArticle(precService.path("참조조문").asText("")); + precedent.setPrecedentContent(precService.path("판례내용").asText("")); + + return precedent; + } + + // ==================== 편의 메서드 ==================== + + /** + * 키워드 검색부터 저장까지 원스톱 처리 + * + * @param query 검색 키워드 + * @return 저장된 건수 + * @throws Exception 처리 중 예외 발생 시 + */ + public int searchAndSaveAll(String query) throws Exception { + // 1. 키워드로 판례일련번호 리스트 가져오기 + List precedentIds = getPrecedentNumbers(query); + if (precedentIds.isEmpty()) { + return 0; + } + + // 2. 일련번호로 상세 조회 + List precedents = getPrecedentDetails(precedentIds); + + // 3. 상세 조회한 판례 저장 + List savedPrecedents = savePrecedents(precedents); + + return savedPrecedents.size(); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/config/QuerydslConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/QuerydslConfig.java new file mode 100644 index 00000000..eb993676 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.ai.lawyer.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QuerydslConfig { + + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} 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 new file mode 100644 index 00000000..55f4f66b --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/dto/PageResponseDto.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.global.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PageResponseDto { + private List content; + private long totalElements; + private int totalPages; + private int pageNumber; + private int pageSize; +}