From c4e63c59a8004beffddab5c116f6525c8d921f46 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Fri, 18 Apr 2025 16:16:35 +0900 Subject: [PATCH 01/25] =?UTF-8?q?=F0=9F=93=A6=20Chore:=20Git-Action=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20(=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B0=B0=ED=8F=AC)=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-deploy.yml | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/dev-deploy.yml diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml new file mode 100644 index 0000000..afa2b0f --- /dev/null +++ b/.github/workflows/dev-deploy.yml @@ -0,0 +1,37 @@ +name: Deploy + +on: + push: + branches: + - dev + paths-ignore: + - '.github/workflows/**' + +jobs: + deploy: + runs-on: self-hosted + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '21' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build Spring Boot (JAR) + run: ./gradlew bootJar + + - name: Copy JAR to shared volume + run: cp build/libs/*.jar /deploy/app.jar + + - name: Restart Docker with New JAR + run: | + cd /deploy + docker-compose down + docker-compose up -d --build From b62fae9a9af59007bc38aac271db5e959d116e51 Mon Sep 17 00:00:00 2001 From: chanbyeong <122460524+chanbyoung@users.noreply.github.com> Date: Fri, 18 Apr 2025 16:17:30 +0900 Subject: [PATCH 02/25] =?UTF-8?q?=E2=9C=A8=20Feature/#13=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=20=EC=9D=91=EB=8B=B5=20=ED=8F=AC=EB=A7=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: 전역 예외 처리 및 표준 응답 포맷 기능 초안 * ✨ Feat: 전역 예외 처리 예시 추가 * 💄Style: 주석문 수정 * ♻️ Refactor: 전역 예외 처리 로직 모듈화 및 BindException 처리 추가 * ♻️ Refactor: ApiResponse 클래스 빌더 패턴 적용 - Lombok의 @Builder, @Getter, @NoArgsConstructor, @AllArgsConstructor 도입 - success() 메서드 오버로드 대신 varargs 기반 단일 메서드로 통합 - SUCCESS_MESSAGE 상수화로 메시지 재사용성 향상 - 코드 가독성과 확장성 개선 * ✨ Feat: 테스트 API 추가 * 💄 Style: 주석문 수정 * ♻️ Refactor: 테스트로 추가했던 종속성 삭제 및 코드 삭제 * 💄 Style: 주석문 형식 변경 --------- Co-authored-by: leelise --- build.gradle | 2 + .../common/exception/CustomException.java | 35 +++++ .../common/exception/error/ErrorCode.java | 61 ++++++++ .../handler/GlobalExceptionHandler.java | 137 ++++++++++++++++++ .../yakplus/response/ApiResponse.java | 89 ++++++++++++ .../scraper/exception/ScraperException.java | 18 +++ .../exception/error/ScraperErrorCode.java | 34 +++++ 7 files changed, 376 insertions(+) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/exception/CustomException.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/exception/error/ErrorCode.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/response/ApiResponse.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java diff --git a/build.gradle b/build.gradle index 00703ef..3520b4e 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,8 @@ dependencies { // Elastic search implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.17.10' + + } tasks.named('test') { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/exception/CustomException.java b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/CustomException.java new file mode 100644 index 0000000..bd8a7a5 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/CustomException.java @@ -0,0 +1,35 @@ +package com.likelion.backendplus4.yakplus.common.exception; + + +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +/** + * 사용자 정의 예외의 추상 클래스 애플리케이션 전역에서 사용하는 공통 예외 상위 타입이다. + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +public abstract class CustomException extends RuntimeException { + + /** + * 생성자 - ErrorCode의 메시지를 기반으로 예외 메시지를 설정한다. + * + * @param errorCode ErrorCode 객체 + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + public CustomException(ErrorCode errorCode) { + super(errorCode.message()); + } + + /** + * ErrorCode 반환 + * + * @return ErrorCode 객체 + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + public abstract ErrorCode getErrorCode(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/exception/error/ErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/error/ErrorCode.java new file mode 100644 index 0000000..d516915 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/error/ErrorCode.java @@ -0,0 +1,61 @@ +package com.likelion.backendplus4.yakplus.common.exception.error; + +import org.springframework.http.HttpStatus; + +/** + * 에러 코드 인터페이스 각 에러 항목에 대한 HTTP 상태, 에러 번호, 메시지를 제공한다. + * A[BB][CCC] + * A (1자리) : 에러 심각도 (1~5) + * 1: 클라이언트 오류 + * 2: 인증 관련 오류 + * 3: 사용자 관련 오류 + * 4: 서버 오류 + * 5: 시스템 오류 + * + * BB (2자리) : 도메인 코드 + * 10: 사용자 관련 (ex: USER_NOT_FOUND) + * 20: 인증 관련 (ex: AUTHORIZATION_FAILED) + * 30: DB 관련 오류 (ex: DB_CONNECTION_FAILED) + * 40: API 관련 오류 (ex: API_TIMEOUT) + * 50: 시스템 오류 (ex: INTERNAL_SERVER_ERROR) + * + * CCC (3자리) : 세부 오류 순번 + * 001: 첫 번째 오류 + * 002: 두 번째 오류 + * 003: 세 번째 오류, 등등 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +public interface ErrorCode { + + /** + * HTTP 상태 반환 + * + * @return HTTP 상태 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + HttpStatus httpStatus(); + + /** + * 에러 코드 번호 반환 + * + * @return 에러 코드 번호 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + int codeNumber(); + + /** + * 에러 메시지 반환 + * + * @return 에러 메시지 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + String message(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..ea70228 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,137 @@ +package com.likelion.backendplus4.yakplus.common.exception.handler; + +import com.likelion.backendplus4.yakplus.common.exception.CustomException; +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; +import com.likelion.backendplus4.yakplus.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.validation.BindException; +import java.util.stream.Collectors; + +/** + * 전역 예외 처리 클래스 컨트롤러에서 발생한 예외를 공통적으로 처리한다. + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 공통 에러 응답 생성 메서드 + * + * 예외 로깅 후 ApiResponse.error를 통해 표준화된 에러 응답을 생성한다. + * + * @param status HTTP 상태 코드 + * @param errorCode 에러 코드 문자열 + * @param message 에러 메시지 + * @param ex 발생한 예외 객체 + * @return ResponseEntity> 형태의 에러 응답 + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-18 + */ + private ResponseEntity> buildErrorResponse( + HttpStatus status, String errorCode, String message, Throwable ex) { + log.error("{}: {}", ex.getClass().getSimpleName(), ex.getMessage(), ex); + return ApiResponse.error(status, errorCode, message); + } + + + /** + * CustomException 처리 ErrorCode 인터페이스 기반으로 확장 가능한 방식으로 처리한다. + * + * @param ex CustomException 객체 + * @return 에러 응답 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException(CustomException ex) { + ErrorCode errorCode = ex.getErrorCode(); + return buildErrorResponse( + errorCode.httpStatus(), + String.valueOf(errorCode.codeNumber()), + errorCode.message(), + ex + ); + } + + /** + * IllegalArgumentException 처리 잘못된 파라미터에 대한 예외 응답 처리 + * + * @param ex 예외 객체 + * @return 에러 응답 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, "300000", ex.getMessage(), ex); + } + + /** + * MethodArgumentNotValidException 처리 유효성 검사 실패에 대한 응답 처리 + * + * @param ex 예외 객체 + * @return 에러 응답 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + String errorMessage = getErrorMessage(ex); + return buildErrorResponse(HttpStatus.BAD_REQUEST, "300001", errorMessage, ex); + } + + /** + * BindException 처리 - GET 요청 파라미터나 폼 바인딩 유효성 실패 시 처리 + * + * @param ex BindException 오류 + * @return 에러 응답 + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-17 + */ + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBindException(BindException ex) { + String errorMessage = getErrorMessage(ex); + return buildErrorResponse(HttpStatus.BAD_REQUEST, "300004", errorMessage, ex); + } + + /** + * BindingResult 분석 후 필드별 오류 메시지 조합 + * + * @return 에러 응답 + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + private static String getErrorMessage(BindException ex) { + return ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) + .collect(Collectors.joining(", ")); + } + + /** + * 기타 모든 예외 처리 정의되지 않은 예외는 내부 서버 오류로 응답 + * + * @param ex 예외 객체 + * @return 에러 응답 + * @author 정안식 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllExceptions(Exception ex) { + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "500000", "내부 서버 오류", ex); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/response/ApiResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/response/ApiResponse.java new file mode 100644 index 0000000..7f7102a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/response/ApiResponse.java @@ -0,0 +1,89 @@ +package com.likelion.backendplus4.yakplus.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import lombok.Getter; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.AccessLevel; + +/** + * API 응답 포맷 클래스 정상 및 에러 응답을 통합된 형식으로 제공한다. + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ApiResponse { + + private static final String SUCCESS_MESSAGE = "요청 성공"; + + private String errorCode; + private String message; + private T data; + + /** + * 정상 응답 생성 (데이터가 없는 경우) + * + * @return 200 OK 응답 (body는 message만 포함) + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-17 + */ + public static ResponseEntity> success() { + ApiResponse body = ApiResponse.builder() + .errorCode(null) + .message(SUCCESS_MESSAGE) + .data(null) + .build(); + return ResponseEntity.ok(body); + } + + /** + * 정상 응답 생성 (데이터가 있는 경우) + * + * @param 응답 데이터 타입 + * @param data 응답 데이터 + * @return 200 OK 응답 (body에 message와 data 포함) + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-17 + */ + public static ResponseEntity> success(T data) { + ApiResponse body = ApiResponse.builder() + .errorCode(null) + .message(SUCCESS_MESSAGE) + .data(data) + .build(); + return ResponseEntity.ok(body); + } + + /** + * 에러 응답 생성 + * + * @param 데이터 타입 + * @param status HTTP 상태 코드 + * @param errorCode 에러 코드 + * @param message 에러 메시지 + * @return 에러 응답 ResponseEntity + * @author 박찬병 + * @modified 2025-04-18 박찬병 + * @since 2025-04-16 + */ + public static ResponseEntity> error(HttpStatus status, String errorCode, String message) { + ApiResponse body = ApiResponse.builder() + .errorCode(errorCode) + .message(message) + .data(null) + .build(); + return ResponseEntity.status(status).body(body); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java new file mode 100644 index 0000000..67174db --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.scraper.exception; + +import com.likelion.backendplus4.yakplus.common.exception.CustomException; +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +public class ScraperException extends CustomException { + private final ErrorCode errorCode; + + public ScraperException(ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java new file mode 100644 index 0000000..124c1b6 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java @@ -0,0 +1,34 @@ +package com.likelion.backendplus4.yakplus.scraper.exception.error; + +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum ScraperErrorCode implements ErrorCode { + + DB_ERROR_PERMIT_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300001, "허가 정보를 조회하는데 실패했습니다."), + DB_ERROR_IMAGE_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300002, "이미지 정보를 조회하는데 실패했습니다."), + DB_ERROR_COMBINED_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300003, "결합된 정보를 조회하는데 실패했습니다."), + API_CONNECT_FAIL(HttpStatus.BAD_GATEWAY, 400001, "외부 API 연결에 실패했습니다."), + PARSING_ERROR(HttpStatus.BAD_REQUEST, 400001, "데이터 파싱에 실패했습니다."); + + private final HttpStatus httpStatus; + private final int code; + private final String message; + + @Override + public HttpStatus httpStatus() { + return null; + } + + @Override + public int codeNumber() { + return 0; + } + + @Override + public String message() { + return ""; + } +} From 5ed36509b218ba447e72d7e8bdff7173e0a80e80 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Fri, 18 Apr 2025 16:24:58 +0900 Subject: [PATCH 03/25] =?UTF-8?q?=F0=9F=9A=A8=20Hotfix:=20#26=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 zcma&Obx`DOvL%eWOXJW;V64viP??$)@wHcsJ68)>bJS6*&iHnskXE8MjvIPVl|FrmV}Npeql07fCw6`pw`0s zGauF(<*@v{3t!qoUU*=j)6;|-(yg@jvDx&fV^trtZt27?4Tkn729qrItVh@PMwG5$ z+oXHSPM??iHZ!cVP~gYact-CwV`}~Q+R}PPNRy+T-geK+>fHrijpllon_F4N{@b-} z1M0=a!VbVmJM8Xk@NRv)m&aRYN}FSJ{LS;}2ArQ5baSjfy40l@T5)1r-^0fAU6f_} zzScst%$Nd-^ElV~H0TetQhMc%S{}Q4lssln=|;LG?Ulo}*mhg8YvBAUY7YFdXs~vv zv~{duzVw%C#GxkBwX=TYp1Dh*Uaum2?RmsvPaLlzO^fIJ`L?&OV?Y&kKj~^kWC`Ly zfL-}J^4a0Ojuz9O{jUbIS;^JatJ5+YNNHe}6nG9Yd6P-lJiK2ms)A^xq^H2fKrTF) zp!6=`Ece~57>^9(RA4OB9;f1FAhV%zVss%#rDq$9ZW3N2cXC7dMz;|UcRFecBm`DA z1pCO!#6zKp#@mx{2>Qcme8y$Qg_gnA%(`Vtg3ccwgb~D(&@y8#Jg8nNYW*-P{_M#E zZ|wCsQoO1(iIKd-2B9xzI}?l#Q@G5d$m1Lfh0q;iS5FDQ&9_2X-H)VDKA*fa{b(sV zL--krNCXibi1+*C2;4qVjb0KWUVGjjRT{A}Q*!cFmj0tRip2ra>WYJ>ZK4C|V~RYs z6;~+*)5F^x^aQqk9tjh)L;DOLlD8j+0<>kHc8MN|68PxQV`tJFbgxSfq-}b(_h`luA0&;Vk<@51i0 z_cu6{_*=vlvYbKjDawLw+t^H?OV00_73Cn3goU5?})UYFuoSX6Xqw;TKcrsc|r# z$sMWYl@cs#SVopO$hpHZ)cdU-+Ui%z&Sa#lMI~zWW@vE%QDh@bTe0&V9nL>4Et9`N zGT8(X{l@A~loDx}BDz`m6@tLv@$mTlVJ;4MGuj!;9Y=%;;_kj#o8n5tX%@M)2I@}u z_{I!^7N1BxW9`g&Z+K#lZ@7_dXdsqp{W9_`)zgZ=sD~%WS5s$`7z#XR!Lfy(4se(m zR@a3twgMs19!-c4jh`PfpJOSU;vShBKD|I0@rmv_x|+ogqslnLLOepJpPMOxhRb*i zGHkwf#?ylQ@k9QJL?!}MY4i7joSzMcEhrDKJH&?2v{-tgCqJe+Y0njl7HYff z{&~M;JUXVR$qM1FPucIEY(IBAuCHC@^~QG6O!dAjzQBxDOR~lJEr4KS9R*idQ^p{D zS#%NQADGbAH~6wAt}(1=Uff-1O#ITe)31zCL$e9~{w)gx)g>?zFE{Bc9nJT6xR!i8 z)l)~9&~zSZTHk{?iQL^MQo$wLi}`B*qnvUy+Y*jEraZMnEhuj`Fu+>b5xD1_Tp z)8|wedv42#3AZUL7x&G@p@&zcUvPkvg=YJS6?1B7ZEXr4b>M+9Gli$gK-Sgh{O@>q7TUg+H zNJj`6q#O@>4HpPJEHvNij`sYW&u%#=215HKNg;C!0#hH1vlO5+dFq9& zS)8{5_%hz?#D#wn&nm@aB?1_|@kpA@{%jYcs{K%$a4W{k@F zPyTav?jb;F(|GaZhm6&M#g|`ckO+|mCtAU)5_(hn&Ogd z9Ku}orOMu@K^Ac>eRh3+0-y^F`j^noa*OkS3p^tLV`TY$F$cPXZJ48!xz1d7%vfA( zUx2+sDPqHfiD-_wJDb38K^LtpN2B0w=$A10z%F9f_P2aDX63w7zDG5CekVQJGy18I zB!tI`6rZr7TK10L(8bpiaQ>S@b7r_u@lh^vakd0e6USWw7W%d_Ob%M!a`K>#I3r-w zo2^+9Y)Sb?P9)x0iA#^ns+Kp{JFF|$09jb6ZS2}_<-=$?^#IUo5;g`4ICZknr!_aJ zd73%QP^e-$%Xjt|28xM}ftD|V@76V_qvNu#?Mt*A-OV{E4_zC4Ymo|(cb+w^`Wv== z>)c%_U0w`d$^`lZQp@midD89ta_qTJW~5lRrIVwjRG_9aRiQGug%f3p@;*%Y@J5uQ|#dJ+P{Omc`d2VR)DXM*=ukjVqIpkb<9gn9{*+&#p)Ek zN=4zwNWHF~=GqcLkd!q0p(S2_K=Q`$whZ}r@ec_cb9hhg9a z6CE=1n8Q;hC?;ujo0numJBSYY6)GTq^=kB~`-qE*h%*V6-ip=c4+Yqs*7C@@b4YAi zuLjsmD!5M7r7d5ZPe>4$;iv|zq=9=;B$lI|xuAJwi~j~^Wuv!Qj2iEPWjh9Z&#+G>lZQpZ@(xfBrhc{rlLwOC;optJZDj4Xfu3$u6rt_=YY0~lxoy~fq=*L_&RmD7dZWBUmY&12S;(Ui^y zBpHR0?Gk|`U&CooNm_(kkO~pK+cC%uVh^cnNn)MZjF@l{_bvn4`Jc}8QwC5_)k$zs zM2qW1Zda%bIgY^3NcfL)9ug`05r5c%8ck)J6{fluBQhVE>h+IA&Kb}~$55m-^c1S3 zJMXGlOk+01qTQUFlh5Jc3xq|7McY$nCs$5=`8Y;|il#Ypb{O9}GJZD8!kYh{TKqs@ z-mQn1K4q$yGeyMcryHQgD6Ra<6^5V(>6_qg`3uxbl|T&cJVA*M_+OC#>w(xL`RoPQ zf1ZCI3G%;o-x>RzO!mc}K!XX{1rih0$~9XeczHgHdPfL}4IPi~5EV#ZcT9 zdgkB3+NPbybS-d;{8%bZW^U+x@Ak+uw;a5JrZH!WbNvl!b~r4*vs#he^bqz`W93PkZna2oYO9dBrKh2QCWt{dGOw)%Su%1bIjtp4dKjZ^ zWfhb$M0MQiDa4)9rkip9DaH0_tv=XxNm>6MKeWv>`KNk@QVkp$Lhq_~>M6S$oliq2 zU6i7bK;TY)m>-}X7hDTie>cc$J|`*}t=MAMfWIALRh2=O{L57{#fA_9LMnrV(HrN6 zG0K_P5^#$eKt{J|#l~U0WN_3)p^LLY(XEqes0OvI?3)GTNY&S13X+9`6PLVFRf8K) z9x@c|2T72+-KOm|kZ@j4EDDec>03FdgQlJ!&FbUQQH+nU^=U3Jyrgu97&#-W4C*;_ z(WacjhBDp@&Yon<9(BWPb;Q?Kc0gR5ZH~aRNkPAWbDY!FiYVSu!~Ss^9067|JCrZk z-{Rn2KEBR|Wti_iy) zXnh2wiU5Yz2L!W{{_#LwNWXeNPHkF=jjXmHC@n*oiz zIoM~Wvo^T@@t!QQW?Ujql-GBOlnB|HjN@x~K8z)c(X}%%5Zcux09vC8=@tvgY>czq z3D(U&FiETaN9aP}FDP3ZSIXIffq>M3{~eTB{uauL07oYiM=~K(XA{SN!rJLyXeC+Y zOdeebgHOc2aCIgC=8>-Q>zfuXV*=a&gp{l#E@K|{qft@YtO>xaF>O7sZz%8);e86? z+jJlFB{0fu6%8ew^_<+v>>%6eB8|t*_v7gb{x=vLLQYJKo;p7^o9!9A1)fZZ8i#ZU z<|E?bZakjkEV8xGi?n+{Xh3EgFKdM^;4D;5fHmc04PI>6oU>>WuLy6jgpPhf8$K4M zjJo*MbN0rZbZ!5DmoC^@hbqXiP^1l7I5;Wtp2i9Jkh+KtDJoXP0O8qmN;Sp(+%upX zAxXs*qlr(ck+-QG_mMx?hQNXVV~LT{$Q$ShX+&x?Q7v z@8t|UDylH6@RZ?WsMVd3B0z5zf50BP6U<&X_}+y3uJ0c5OD}+J&2T8}A%2Hu#Nt_4 zoOoTI$A!hQ<2pk5wfZDv+7Z{yo+Etqry=$!*pvYyS+kA4xnJ~3b~TBmA8Qd){w_bE zqDaLIjnU8m$wG#&T!}{e0qmHHipA{$j`%KN{&#_Kmjd&#X-hQN+ju$5Ms$iHj4r?) z&5m8tI}L$ih&95AjQ9EDfPKSmMj-@j?Q+h~C3<|Lg2zVtfKz=ft{YaQ1i6Om&EMll zzov%MsjSg=u^%EfnO+W}@)O6u0LwoX709h3Cxdc2Rwgjd%LLTChQvHZ+y<1q6kbJXj3_pq1&MBE{8 zd;aFotyW>4WHB{JSD8Z9M@jBitC1RF;!B8;Rf-B4nOiVbGlh9w51(8WjL&e{_iXN( zAvuMDIm_>L?rJPxc>S`bqC|W$njA0MKWa?V$u6mN@PLKYqak!bR!b%c^ze(M`ec(x zv500337YCT4gO3+9>oVIJLv$pkf`01S(DUM+4u!HQob|IFHJHm#>eb#eB1X5;bMc| z>QA4Zv}$S?fWg~31?Lr(C>MKhZg>gplRm`2WZ--iw%&&YlneQYY|PXl;_4*>vkp;I z$VYTZq|B*(3(y17#@ud@o)XUZPYN*rStQg5U1Sm2gM}7hf_G<>*T%6ebK*tF(kbJc zNPH4*xMnJNgw!ff{YXrhL&V$6`ylY={qT_xg9znQWw9>PlG~IbhnpsG_94Kk_(V-o&v7#F znra%uD-}KOX2dkak**hJnZZQyp#ERyyV^lNe!Qrg=VHiyr7*%j#PMvZMuYNE8o;JM zGrnDWmGGy)(UX{rLzJ*QEBd(VwMBXnJ@>*F8eOFy|FK*Vi0tYDw;#E zu#6eS;%Nm2KY+7dHGT3m{TM7sl=z8|V0e!DzEkY-RG8vTWDdSQFE|?+&FYA146@|y zV(JP>LWL;TSL6rao@W5fWqM1-xr$gRci#RQV2DX-x4@`w{uEUgoH4G|`J%H!N?*Qn zy~rjzuf(E7E!A9R2bSF|{{U(zO+;e29K_dGmC^p7MCP!=Bzq@}&AdF5=rtCwka zTT1A?5o}i*sXCsRXBt)`?nOL$zxuP3i*rm3Gmbmr6}9HCLvL*45d|(zP;q&(v%}S5yBmRVdYQQ24zh z6qL2<2>StU$_Ft29IyF!6=!@;tW=o8vNzVy*hh}XhZhUbxa&;9~woye<_YmkUZ)S?PW{7t; zmr%({tBlRLx=ffLd60`e{PQR3NUniWN2W^~7Sy~MPJ>A#!6PLnlw7O0(`=PgA}JLZ ztqhiNcKvobCcBel2 z-N82?4-()eGOisnWcQ9Wp23|ybG?*g!2j#>m3~0__IX1o%dG4b;VF@^B+mRgKx|ij zWr5G4jiRy}5n*(qu!W`y54Y*t8g`$YrjSunUmOsqykYB4-D(*(A~?QpuFWh;)A;5= zPl|=x+-w&H9B7EZGjUMqXT}MkcSfF}bHeRFLttu!vHD{Aq)3HVhvtZY^&-lxYb2%` zDXk7>V#WzPfJs6u{?ZhXpsMdm3kZscOc<^P&e&684Rc1-d=+=VOB)NR;{?0NjTl~D z1MXak$#X4{VNJyD$b;U~Q@;zlGoPc@ny!u7Pe;N2l4;i8Q=8>R3H{>HU(z z%hV2?rSinAg6&wuv1DmXok`5@a3@H0BrqsF~L$pRYHNEXXuRIWom0l zR9hrZpn1LoYc+G@q@VsFyMDNX;>_Vf%4>6$Y@j;KSK#g)TZRmjJxB!_NmUMTY(cAV zmewn7H{z`M3^Z& z2O$pWlDuZHAQJ{xjA}B;fuojAj8WxhO}_9>qd0|p0nBXS6IIRMX|8Qa!YDD{9NYYK z%JZrk2!Ss(Ra@NRW<7U#%8SZdWMFDU@;q<}%F{|6n#Y|?FaBgV$7!@|=NSVoxlJI4G-G(rn}bh|?mKkaBF$-Yr zA;t0r?^5Nz;u6gwxURapQ0$(-su(S+24Ffmx-aP(@8d>GhMtC5x*iEXIKthE*mk$` zOj!Uri|EAb4>03C1xaC#(q_I<;t}U7;1JqISVHz3tO{) zD(Yu@=>I9FDmDtUiWt81;BeaU{_=es^#QI7>uYl@e$$lGeZ~Q(f$?^3>$<<{n`Bn$ zn8bamZlL@6r^RZHV_c5WV7m2(G6X|OI!+04eAnNA5=0v1Z3lxml2#p~Zo57ri;4>;#16sSXXEK#QlH>=b$inEH0`G#<_ zvp;{+iY)BgX$R!`HmB{S&1TrS=V;*5SB$7*&%4rf_2wQS2ed2E%Wtz@y$4ecq4w<) z-?1vz_&u>s?BMrCQG6t9;t&gvYz;@K@$k!Zi=`tgpw*v-#U1Pxy%S9%52`uf$XMv~ zU}7FR5L4F<#9i%$P=t29nX9VBVv)-y7S$ZW;gmMVBvT$BT8d}B#XV^@;wXErJ-W2A zA=JftQRL>vNO(!n4mcd3O27bHYZD!a0kI)6b4hzzL9)l-OqWn)a~{VP;=Uo|D~?AY z#8grAAASNOkFMbRDdlqVUfB;GIS-B-_YXNlT_8~a|LvRMVXf!<^uy;)d$^OR(u)!) zHHH=FqJF-*BXif9uP~`SXlt0pYx|W&7jQnCbjy|8b-i>NWb@!6bx;1L&$v&+!%9BZ z0nN-l`&}xvv|wwxmC-ZmoFT_B#BzgQZxtm|4N+|;+(YW&Jtj^g!)iqPG++Z%x0LmqnF875%Ry&2QcCamx!T@FgE@H zN39P6e#I5y6Yl&K4eUP{^biV`u9{&CiCG#U6xgGRQr)zew;Z%x+ z-gC>y%gvx|dM=OrO`N@P+h2klPtbYvjS!mNnk4yE0+I&YrSRi?F^plh}hIp_+OKd#o7ID;b;%*c0ES z!J))9D&YufGIvNVwT|qsGWiZAwFODugFQ$VsNS%gMi8OJ#i${a4!E3<-4Jj<9SdSY z&xe|D0V1c`dZv+$8>(}RE|zL{E3 z-$5Anhp#7}oO(xm#}tF+W=KE*3(xxKxhBt-uuJP}`_K#0A< zE%rhMg?=b$ot^i@BhE3&)bNBpt1V*O`g?8hhcsV-n#=|9wGCOYt8`^#T&H7{U`yt2 z{l9Xl5CVsE=`)w4A^%PbIR6uG_5Ww9k`=q<@t9Bu662;o{8PTjDBzzbY#tL;$wrpjONqZ{^Ds4oanFm~uyPm#y1Ll3(H57YDWk9TlC zq;kebC!e=`FU&q2ojmz~GeLxaJHfs0#F%c(i+~gg$#$XOHIi@1mA72g2pFEdZSvp}m0zgQb5u2?tSRp#oo!bp`FP}< zaK4iuMpH+Jg{bb7n9N6eR*NZfgL7QiLxI zk6{uKr>xxJ42sR%bJ%m8QgrL|fzo9@?9eQiMW8O`j3teoO_R8cXPe_XiLnlYkE3U4 zN!^F)Z4ZWcA8gekEPLtFqX-Q~)te`LZnJK_pgdKs)Dp50 zdUq)JjlJeELskKg^6KY!sIou-HUnSFRsqG^lsHuRs`Z{f(Ti9eyd3cwu*Kxp?Ws7l z3cN>hGPXTnQK@qBgqz(n*qdJ2wbafELi?b90fK~+#XIkFGU4+HihnWq;{{)1J zv*Txl@GlnIMOjzjA1z%g?GsB2(6Zb-8fooT*8b0KF2CdsIw}~Hir$d3TdVHRx1m3c z4C3#h@1Xi@{t4zge-#B6jo*ChO%s-R%+9%-E|y<*4;L>$766RiygaLR?X%izyqMXA zb|N=Z-0PSFeH;W6aQ3(5VZWVC>5Ibgi&cj*c%_3=o#VyUJv* zM&bjyFOzlaFq;ZW(q?|yyi|_zS%oIuH^T*MZ6NNXBj;&yM3eQ7!CqXY?`7+*+GN47 zNR#%*ZH<^x{(0@hS8l{seisY~IE*)BD+R6^OJX}<2HRzo^fC$n>#yTOAZbk4%=Bei=JEe=o$jm`or0YDw*G?d> z=i$eEL7^}_?UI^9$;1Tn9b>$KOM@NAnvWrcru)r`?LodV%lz55O3y(%FqN;cKgj7t zlJ7BmLTQ*NDX#uelGbCY>k+&H*iSK?x-{w;f5G%%!^e4QT9z<_0vHbXW^MLR} zeC*jezrU|{*_F`I0mi)9=sUj^G03i@MjXx@ePv@(Udt2CCXVOJhRh4yp~fpn>ssHZ z?k(C>2uOMWKW5FVsBo#Nk!oqYbL`?#i~#!{3w^qmCto05uS|hKkT+iPrC-}hU_nbL zO622#mJupB21nChpime}&M1+whF2XM?prT-Vv)|EjWYK(yGYwJLRRMCkx;nMSpu?0 zNwa*{0n+Yg6=SR3-S&;vq=-lRqN`s9~#)OOaIcy3GZ&~l4g@2h| zThAN#=dh{3UN7Xil;nb8@%)wx5t!l z0RSe_yJQ+_y#qEYy$B)m2yDlul^|m9V2Ia$1CKi6Q19~GTbzqk*{y4;ew=_B4V8zw zScDH&QedBl&M*-S+bH}@IZUSkUfleyM45G>CnYY{hx8J9q}ME?Iv%XK`#DJRNmAYt zk2uY?A*uyBA=nlYjkcNPMGi*552=*Q>%l?gDK_XYh*Rya_c)ve{=ps`QYE0n!n!)_$TrGi_}J|>1v}(VE7I~aP-wns#?>Y zu+O7`5kq32zM4mAQpJ50vJsUDT_^s&^k-llQMy9!@wRnxw@~kXV6{;z_wLu3i=F3m z&eVsJmuauY)8(<=pNUM5!!fQ4uA6hBkJoElL1asWNkYE#qaP?a+biwWw~vB48PRS7 zY;DSHvgbIB$)!uJU)xA!yLE*kP0owzYo`v@wfdux#~f!dv#uNc_$SF@Qq9#3q5R zfuQnPPN_(z;#X#nRHTV>TWL_Q%}5N-a=PhkQ^GL+$=QYfoDr2JO-zo#j;mCsZVUQ) zJ96e^OqdLW6b-T@CW@eQg)EgIS9*k`xr$1yDa1NWqQ|gF^2pn#dP}3NjfRYx$pTrb zwGrf8=bQAjXx*8?du*?rlH2x~^pXjiEmj^XwQo{`NMonBN=Q@Y21!H)D( zA~%|VhiTjaRQ%|#Q9d*K4j~JDXOa4wmHb0L)hn*;Eq#*GI}@#ux4}bt+olS(M4$>c z=v8x74V_5~xH$sP+LZCTrMxi)VC%(Dg!2)KvW|Wwj@pwmH6%8zd*x0rUUe$e(Z%AW z@Q{4LL9#(A-9QaY2*+q8Yq2P`pbk3!V3mJkh3uH~uN)+p?67d(r|Vo0CebgR#u}i? zBxa^w%U|7QytN%L9bKaeYhwdg7(z=AoMeP0)M3XZA)NnyqL%D_x-(jXp&tp*`%Qsx z6}=lGr;^m1<{;e=QQZ!FNxvLcvJVGPkJ63at5%*`W?46!6|5FHYV0qhizSMT>Zoe8 zsJ48kb2@=*txGRe;?~KhZgr-ZZ&c0rNV7eK+h$I-UvQ=552@psVrvj#Ys@EU4p8`3 zsNqJu-o=#@9N!Pq`}<=|((u)>^r0k^*%r<{YTMm+mOPL>EoSREuQc-e2~C#ZQ&Xve zZ}OUzmE4{N-7cqhJiUoO_V#(nHX11fdfVZJT>|6CJGX5RQ+Ng$Nq9xs-C86-)~`>p zW--X53J`O~vS{WWjsAuGq{K#8f#2iz` zzSSNIf6;?5sXrHig%X(}0q^Y=eYwvh{TWK-fT>($8Ex>!vo_oGFw#ncr{vmERi^m7lRi%8Imph})ZopLoIWt*eFWSPuBK zu>;Pu2B#+e_W|IZ0_Q9E9(s@0>C*1ft`V{*UWz^K<0Ispxi@4umgGXW!j%7n+NC~* zBDhZ~k6sS44(G}*zg||X#9Weto;u*Ty;fP!+v*7be%cYG|yEOBomch#m8Np!Sw`L)q+T` zmrTMf2^}7j=RPwgpO9@eXfb{Q>GW#{X=+xt`AwTl!=TgYm)aS2x5*`FSUaaP_I{Xi zA#irF%G33Bw>t?^1YqX%czv|JF0+@Pzi%!KJ?z!u$A`Catug*tYPO`_Zho5iip0@! z;`rR0-|Ao!YUO3yaujlSQ+j-@*{m9dHLtve!sY1Xq_T2L3&=8N;n!!Eb8P0Z^p4PL zQDdZ?An2uzbIakOpC|d@=xEA}v-srucnX3Ym{~I#Ghl~JZU(a~Ppo9Gy1oZH&Wh%y zI=KH_s!Lm%lAY&`_KGm*Ht)j*C{-t}Nn71drvS!o|I|g>ZKjE3&Mq0TCs6}W;p>%M zQ(e!h*U~b;rsZ1OPigud>ej=&hRzs@b>>sq6@Yjhnw?M26YLnDH_Wt#*7S$-BtL08 zVyIKBm$}^vp?ILpIJetMkW1VtIc&7P3z0M|{y5gA!Yi5x4}UNz5C0Wdh02!h zNS>923}vrkzl07CX`hi)nj-B?#n?BJ2Vk0zOGsF<~{Fo7OMCN_85daxhk*pO}x_8;-h>}pcw26V6CqR-=x2vRL?GB#y%tYqi;J}kvxaz}*iFO6YO0ha6!fHU9#UI2Nv z_(`F#QU1B+P;E!t#Lb)^KaQYYSewj4L!_w$RH%@IL-M($?DV@lGj%3ZgVdHe^q>n(x zyd5PDpGbvR-&p*eU9$#e5#g3-W_Z@loCSz}f~{94>k6VRG`e5lI=SE0AJ7Z_+=nnE zTuHEW)W|a8{fJS>2TaX zuRoa=LCP~kP)kx4L+OqTjtJOtXiF=y;*eUFgCn^Y@`gtyp?n14PvWF=zhNGGsM{R- z^DsGxtoDtx+g^hZi@E2Y(msb-hm{dWiHdoQvdX88EdM>^DS#f}&kCGpPFDu*KjEpv$FZtLpeT>@)mf|z#ZWEsueeW~hF78Hu zfY9a+Gp?<)s{Poh_qdcSATV2oZJo$OH~K@QzE2kCADZ@xX(; z)0i=kcAi%nvlsYagvUp(z0>3`39iKG9WBDu3z)h38p|hLGdD+Khk394PF3qkX!02H z#rNE`T~P9vwNQ_pNe0toMCRCBHuJUmNUl)KFn6Gu2je+p>{<9^oZ4Gfb!)rLZ3CR3 z-o&b;Bh>51JOt=)$-9+Z!P}c@cKev_4F1ZZGs$I(A{*PoK!6j@ZJrAt zv2LxN#p1z2_0Ox|Q8PVblp9N${kXkpsNVa^tNWhof)8x8&VxywcJz#7&P&d8vvxn` zt75mu>yV=Dl#SuiV!^1BPh5R)`}k@Nr2+s8VGp?%Le>+fa{3&(XYi~{k{ z-u4#CgYIdhp~GxLC+_wT%I*)tm4=w;ErgmAt<5i6c~)7JD2olIaK8by{u-!tZWT#RQddptXRfEZxmfpt|@bs<*uh?Y_< zD>W09Iy4iM@@80&!e^~gj!N`3lZwosC!!ydvJtc0nH==K)v#ta_I}4Tar|;TLb|+) zSF(;=?$Z0?ZFdG6>Qz)6oPM}y1&zx_Mf`A&chb znSERvt9%wdPDBIU(07X+CY74u`J{@SSgesGy~)!Mqr#yV6$=w-dO;C`JDmv=YciTH zvcrN1kVvq|(3O)NNdth>X?ftc`W2X|FGnWV%s})+uV*bw>aoJ#0|$pIqK6K0Lw!@- z3pkPbzd`ljS=H2Bt0NYe)u+%kU%DWwWa>^vKo=lzDZHr>ruL5Ky&#q7davj-_$C6J z>V8D-XJ}0cL$8}Xud{T_{19#W5y}D9HT~$&YY-@=Th219U+#nT{tu=d|B)3K`pL53 zf7`I*|L@^dPEIDJkI3_oA9vsH7n7O}JaR{G~8 zfi$?kmKvu20(l`dV7=0S43VwVKvtF!7njv1Q{Ju#ysj=|dASq&iTE8ZTbd-iiu|2& zmll%Ee1|M?n9pf~?_tdQ<7%JA53!ulo1b^h#s|Su2S4r{TH7BRB3iIOiX5|vc^;5( zKfE1+ah18YA9o1EPT(AhBtve5(%GMbspXV)|1wf5VdvzeYt8GVGt0e*3|ELBhwRaO zE|yMhl;Bm?8Ju3-;DNnxM3Roelg`^!S%e({t)jvYtJCKPqN`LmMg^V&S z$9OIFLF$%Py~{l?#ReyMzpWixvm(n(Y^Am*#>atEZ8#YD&?>NUU=zLxOdSh0m6mL? z_twklB0SjM!3+7U^>-vV=KyQZI-6<(EZiwmNBzGy;Sjc#hQk%D;bay$v#zczt%mFCHL*817X4R;E$~N5(N$1Tv{VZh7d4mhu?HgkE>O+^-C*R@ zR0ima8PsEV*WFvz`NaB+lhX3&LUZcWWJJrG7ZjQrOWD%_jxv=)`cbCk zMgelcftZ%1-p9u!I-Zf_LLz{hcn5NRbxkWby@sj2XmYfAV?iw^0?hM<$&ZDctdC`; zsL|C-7d;w$z2Gt0@hsltNlytoPnK&$>ksr(=>!7}Vk#;)Hp)LuA7(2(Hh(y3LcxRY zim!`~j6`~B+sRBv4 z<#B{@38kH;sLB4eH2+8IPWklhd25r5j2VR}YK$lpZ%7eVF5CBr#~=kUp`i zlb+>Z%i%BJH}5dmfg1>h7U5Q(-F{1d=aHDbMv9TugohX5lq#szPAvPE|HaokMQIi_ zTcTNsO53(oX=hg2w!XA&+qP}nwr$(C)pgG8emS@Mf7m0&*kiA!wPLS`88c=aD$niJ zp?3j%NI^uy|5*MzF`k4hFbsyQZ@wu!*IY+U&&9PwumdmyfL(S0#!2RFfmtzD3m9V7 zsNOw9RQofl-XBfKBF^~~{oUVouka#r3EqRf=SnleD=r1Hm@~`y8U7R)w16fgHvK-6?-TFth)f3WlklbZh+}0 zx*}7oDF4U^1tX4^$qd%987I}g;+o0*$Gsd=J>~Uae~XY6UtbdF)J8TzJXoSrqHVC) zJ@pMgE#;zmuz?N2MIC+{&)tx=7A%$yq-{GAzyz zLzZLf=%2Jqy8wGHD;>^x57VG)sDZxU+EMfe0L{@1DtxrFOp)=zKY1i%HUf~Dro#8} zUw_Mj10K7iDsX}+fThqhb@&GI7PwONx!5z;`yLmB_92z0sBd#HiqTzDvAsTdx+%W{ z2YL#U=9r!@3pNXMp_nvximh+@HV3psUaVa-lOBekVuMf1RUd26~P*|MLouQrb}XM-bEw(UgQxMI6M&l3Nha z{MBcV=tl(b_4}oFdAo}WX$~$Mj-z70FowdoB{TN|h2BdYs?$imcj{IQpEf9q z)rzpttc0?iwopSmEoB&V!1aoZqEWEeO-MKMx(4iK7&Fhc(94c zdy}SOnSCOHX+A8q@i>gB@mQ~Anv|yiUsW!bO9hb&5JqTfDit9X6xDEz*mQEiNu$ay zwqkTV%WLat|Ar+xCOfYs0UQNM`sdsnn*zJr>5T=qOU4#Z(d90!IL76DaHIZeWKyE1 zqwN%9+~lPf2d7)vN2*Q?En?DEPcM+GQwvA<#;X3v=fqsxmjYtLJpc3)A8~*g(KqFx zZEnqqruFDnEagXUM>TC7ngwKMjc2Gx%#Ll#=N4qkOuK|;>4%=0Xl7k`E69@QJ-*Vq zk9p5!+Ek#bjuPa<@Xv7ku4uiWo|_wy)6tIr`aO!)h>m5zaMS-@{HGIXJ0UilA7*I} z?|NZ!Tp8@o-lnyde*H+@8IHME8VTQOGh96&XX3E+}OB zA>VLAGW+urF&J{H{9Gj3&u+Gyn?JAVW84_XBeGs1;mm?2SQm9^!3UE@(_FiMwgkJI zZ*caE={wMm`7>9R?z3Ewg!{PdFDrbzCmz=RF<@(yQJ_A6?PCd_MdUf5vv6G#9Mf)i#G z($OxDT~8RNZ>1R-vw|nN699a}MQN4gJE_9gA-0%>a?Q<9;f3ymgoi$OI!=aE6Elw z2I`l!qe-1J$T$X&x9Zz#;3!P$I);jdOgYY1nqny-k=4|Q4F!mkqACSN`blRji>z1` zc8M57`~1lgL+Ha%@V9_G($HFBXH%k;Swyr>EsQvg%6rNi){Tr&+NAMga2;@85531V z_h+h{jdB&-l+%aY{$oy2hQfx`d{&?#psJ78iXrhrO)McOFt-o80(W^LKM{Zw93O}m z;}G!51qE?hi=Gk2VRUL2kYOBRuAzktql%_KYF4>944&lJKfbr+uo@)hklCHkC=i)E zE*%WbWr@9zoNjumq|kT<9Hm*%&ahcQ)|TCjp@uymEU!&mqqgS;d|v)QlBsE0Jw|+^ zFi9xty2hOk?rlGYT3)Q7i4k65@$RJ-d<38o<`}3KsOR}t8sAShiVWevR8z^Si4>dS z)$&ILfZ9?H#H&lumngpj7`|rKQQ`|tmMmFR+y-9PP`;-425w+#PRKKnx7o-Rw8;}*Ctyw zKh~1oJ5+0hNZ79!1fb(t7IqD8*O1I_hM;o*V~vd_LKqu7c_thyLalEF8Y3oAV=ODv z$F_m(Z>ucO(@?+g_vZ`S9+=~Msu6W-V5I-V6h7->50nQ@+TELlpl{SIfYYNvS6T6D z`9cq=at#zEZUmTfTiM3*vUamr!OB~g$#?9$&QiwDMbSaEmciWf3O2E8?oE0ApScg38hb&iN%K+kvRt#d))-tr^ zD+%!d`i!OOE3in0Q_HzNXE!JcZ<0;cu6P_@;_TIyMZ@Wv!J z)HSXAYKE%-oBk`Ye@W3ShYu-bfCAZ}1|J16hFnLy z?Bmg2_kLhlZ*?`5R8(1%Y?{O?xT)IMv{-)VWa9#1pKH|oVRm4!lLmls=u}Lxs44@g^Zwa0Z_h>Rk<(_mHN47=Id4oba zQ-=qXGz^cNX(b*=NT0<^23+hpS&#OXzzVO@$Z2)D`@oS=#(s+eQ@+FSQcpXD@9npp zlxNC&q-PFU6|!;RiM`?o&Sj&)<4xG3#ozRyQxcW4=EE;E)wcZ&zUG*5elg;{9!j}I z9slay#_bb<)N!IKO16`n3^@w=Y%duKA-{8q``*!w9SW|SRbxcNl50{k&CsV@b`5Xg zWGZ1lX)zs_M65Yt&lO%mG0^IFxzE_CL_6$rDFc&#xX5EXEKbV8E2FOAt>Ka@e0aHQ zMBf>J$FLrCGL@$VgPKSbRkkqo>sOXmU!Yx+Dp7E3SRfT`v~!mjU3qj-*!!YjgI*^) z+*05x78FVnVwSGKr^A|FW*0B|HYgc{c;e3Ld}z4rMI7hVBKaiJRL_e$rxDW^8!nGLdJ<7ex9dFoyj|EkODflJ#Xl`j&bTO%=$v)c+gJsLK_%H3}A_} z6%rfG?a7+k7Bl(HW;wQ7BwY=YFMSR3J43?!;#~E&)-RV_L!|S%XEPYl&#`s!LcF>l zn&K8eemu&CJp2hOHJKaYU#hxEutr+O161ze&=j3w12)UKS%+LAwbjqR8sDoZHnD=m0(p62!zg zxt!Sj65S?6WPmm zL&U9c`6G}T`irf=NcOiZ!V)qhnvMNOPjVkyO2^CGJ+dKTnNAPa?!AxZEpO7yL_LkB zWpolpaDfSaO-&Uv=dj7`03^BT3_HJOAjn~X;wz-}03kNs@D^()_{*BD|0mII!J>5p z1h06PTyM#3BWzAz1FPewjtrQfvecWhkRR=^gKeFDe$rmaYAo!np6iuio3>$w?az$E zwGH|zy@OgvuXok}C)o1_&N6B3P7ZX&-yimXc1hAbXr!K&vclCL%hjVF$yHpK6i_Wa z*CMg1RAH1(EuuA01@lA$sMfe*s@9- z$jNWqM;a%d3?(>Hzp*MiOUM*?8eJ$=(0fYFis!YA;0m8s^Q=M0Hx4ai3eLn%CBm14 zOb8lfI!^UAu_RkuHmKA-8gx8Z;##oCpZV{{NlNSe<i;9!MfIN!&;JI-{|n{(A19|s z9oiGesENcLf@NN^9R0uIrgg(46r%kjR{0SbnjBqPq()wDJ@LC2{kUu_j$VR=l`#RdaRe zxx;b7bu+@IntWaV$si1_nrQpo*IWGLBhhMS13qH zTy4NpK<-3aVc;M)5v(8JeksSAGQJ%6(PXGnQ-g^GQPh|xCop?zVXlFz>42%rbP@jg z)n)% zM9anq5(R=uo4tq~W7wES$g|Ko z1iNIw@-{x@xKxSXAuTx@SEcw(%E49+JJCpT(y=d+n9PO0Gv1SmHkYbcxPgDHF}4iY zkXU4rkqkwVBz<{mcv~A0K|{zpX}aJcty9s(u-$je2&=1u(e#Q~UA{gA!f;0EAaDzdQ=}x7g(9gWrWYe~ zV98=VkHbI!5Rr;+SM;*#tOgYNlfr7;nLU~MD^jSdSpn@gYOa$TQPv+e8DyJ&>aInB zDk>JmjH=}<4H4N4z&QeFx>1VPY8GU&^1c&71T*@2#dINft%ibtY(bAm%<2YwPL?J0Mt{ z7l7BR718o5=v|jB!<7PDBafdL>?cCdVmKC;)MCOobo5edt%RTWiReAMaIU5X9h`@El0sR&Z z7Ed+FiyA+QAyWn zf7=%(8XpcS*C4^-L24TBUu%0;@s!Nzy{e95qjgkzElf0#ou`sYng<}wG1M|L? zKl6ITA1X9mt6o@S(#R3B{uwJI8O$&<3{+A?T~t>Kapx6#QJDol6%?i-{b1aRu?&9B z*W@$T*o&IQ&5Kc*4LK_)MK-f&Ys^OJ9FfE?0SDbAPd(RB)Oju#S(LK)?EVandS1qb#KR;OP|86J?;TqI%E8`vszd&-kS%&~;1Als=NaLzRNnj4q=+ zu5H#z)BDKHo1EJTC?Cd_oq0qEqNAF8PwU7fK!-WwVEp4~4g z3SEmE3-$ddli))xY9KN$lxEIfyLzup@utHn=Q{OCoz9?>u%L^JjClW$M8OB`txg4r6Q-6UlVx3tR%%Z!VMb6#|BKRL`I))#g zij8#9gk|p&Iwv+4s+=XRDW7VQrI(+9>DikEq!_6vIX8$>poDjSYIPcju%=qluSS&j zI-~+ztl1f71O-B+s7Hf>AZ#}DNSf`7C7*)%(Xzf|ps6Dr7IOGSR417xsU=Rxb z1pgk9vv${17h7mZ{)*R{mc%R=!i}8EFV9pl8V=nXCZruBff`$cqN3tpB&RK^$yH!A8RL zJ5KltH$&5%xC7pLZD}6wjD2-uq3&XL8CM$@V9jqalF{mvZ)c4Vn?xXbvkB(q%xbSdjoXJXanVN@I;8I`)XlBX@6BjuQKD28Jrg05} z^ImmK-Ux*QMn_A|1ionE#AurP8Vi?x)7jG?v#YyVe_9^up@6^t_Zy^T1yKW*t* z&Z0+0Eo(==98ig=^`he&G^K$I!F~1l~gq}%o5#pR6?T+ zLmZu&_ekx%^nys<^tC@)s$kD`^r8)1^tUazRkWEYPw0P)=%cqnyeFo3nW zyV$^0DXPKn5^QiOtOi4MIX^#3wBPJjenU#2OIAgCHPKXv$OY=e;yf7+_vI7KcjKq% z?RVzC24ekYp2lEhIE^J$l&wNX0<}1Poir8PjM`m#zwk-AL0w6WvltT}*JN8WFmtP_ z6#rK7$6S!nS!}PSFTG6AF7giGJw5%A%14ECde3x95(%>&W3zUF!8x5%*h-zk8b@Bz zh`7@ixoCVCZ&$$*YUJpur90Yg0X-P82>c~NMzDy7@Ed|6(#`;{)%t7#Yb>*DBiXC3 zUFq(UDFjrgOsc%0KJ_L;WQKF0q!MINpQzSsqwv?#Wg+-NO; z84#4nk$+3C{2f#}TrRhin=Erdfs77TqBSvmxm0P?01Tn@V(}gI_ltHRzQKPyvQ2=M zX#i1-a(>FPaESNx+wZ6J{^m_q3i})1n~JG80c<%-Ky!ZdTs8cn{qWY%x%X^27-Or_ z`KjiUE$OG9K4lWS16+?aak__C*)XA{ z6HmS*8#t_3dl}4;7ZZgn4|Tyy1lOEM1~6Qgl(|BgfQF{Mfjktch zB5kc~4NeehRYO%)3Z!FFHhUVVcV@uEX$eft5Qn&V3g;}hScW_d)K_h5i)vxjKCxcf zL>XlZ^*pQNuX*RJQn)b6;blT3<7@Ap)55)aK3n-H08GIx65W zO9B%gE%`!fyT`)hKjm-&=on)l&!i-QH+mXQ&lbXg0d|F{Ac#U;6b$pqQcpqWSgAPo zmr$gOoE*0r#7J=cu1$5YZE%uylM!i3L{;GW{ae9uy)+EaV>GqW6QJ)*B2)-W`|kLL z)EeeBtpgm;79U_1;Ni5!c^0RbG8yZ0W98JiG~TC8rjFRjGc6Zi8BtoC);q1@8h7UV zFa&LRzYsq%6d!o5-yrqyjXi>jg&c8bu}{Bz9F2D(B%nnuVAz74zmBGv)PAdFXS2(A z=Z?uupM2f-ar0!A)C6l2o8a|+uT*~huH)!h3i!&$ zr>76mt|lwexD(W_+5R{e@2SwR15lGxsnEy|gbS-s5?U}l*kcfQlfnQKo5=LZXizrL zM=0ty+$#f_qGGri-*t@LfGS?%7&LigUIU#JXvwEdJZvIgPCWFBTPT`@Re5z%%tRDO zkMlJCoqf2A=hkU7Ih=IxmPF~fEL90)u76nfFRQwe{m7b&Ww$pnk~$4Lx#s9|($Cvt ze|p{Xozhb^g1MNh-PqS_dLY|Fex4|rhM#lmzq&mhebD$5P>M$eqLoV|z=VQY{)7&sR#tW zl(S1i!!Rrg7kv+V@EL51PGpm511he%MbX2-Jl+DtyYA(0gZyZQjPZP@`SAH{n&25@ zd)emg(p2T3$A!Nmzo|%=z%AhLX)W4hsZNFhmd4<1l6?b3&Fg)G(Zh%J{Cf8Q;?_++ zgO7O<(-)H|Es@QqUgcXNJEfC-BCB~#dhi6ADVZtL!)Mx|u7>ukD052z!QZ5UC-+rd zYXWNRpCmdM{&?M9OMa;OiN{Y#0+F>lBQ=W@M;OXq;-7v3niC$pM8p!agNmq7F04;| z@s-_98JJB&s`Pr6o$KZ=8}qO*7m6SMp7kVmmh$jfnG{r@O(auI7Z^jj!x}NTLS9>k zdo}&Qc2m4Ws3)5qFw#<$h=g%+QUKiYog33bE)e4*H~6tfd42q+|FT5+vmr6Y$6HGC zV!!q>B`1Ho|6E|D<2tYE;4`8WRfm2#AVBBn%_W)mi(~x@g;uyQV3_)~!#A6kmFy0p zY~#!R1%h5E{5;rehP%-#kjMLt*{g((o@0-9*8lKVu+t~CtnOxuaMgo2ssI6@kX09{ zkn~q8Gx<6T)l}7tWYS#q0&~x|-3ho@l}qIr79qOJQcm&Kfr7H54=BQto0)vd1A_*V z)8b2{xa5O^u95~TS=HcJF5b9gMV%&M6uaj<>E zPNM~qGjJ~xbg%QTy#(hPtfc46^nN=Y_GmPYY_hTL{q`W3NedZyRL^kgU@Q$_KMAjEzz*eip`3u6AhPDcWXzR=Io5EtZRPme>#K9 z4lN&87i%YYjoCKN_z9YK+{fJu{yrriba#oGM|2l$ir017UH86Eoig3x+;bz32R*;n zt)Eyg#PhQbbGr^naCv0?H<=@+Poz)Xw*3Gn00qdSL|zGiyYKOA0CP%qk=rBAlt~hr zEvd3Z4nfW%g|c`_sfK$z8fWsXTQm@@eI-FpLGrW<^PIjYw)XC-xFk+M<6>MfG;WJr zuN}7b;p^`uc0j(73^=XJcw;|D4B(`)Flm|qEbB?>qBBv2V?`mWA?Q3yRdLkK7b}y& z+!3!JBI{+&`~;%Pj#n&&y+<;IQzw5SvqlbC+V=kLZLAHOQb zS{{8E&JXy1p|B&$K!T*GKtSV^{|Uk;`oE*F;?@q1dX|>|KWb@|Dy*lbGV0Gx;gpA$ z*N16`v*gQ?6Skw(f^|SL;;^ox6jf2AQ$Zl?gvEV&H|-ep*hIS@0TmGu1X1ZmEPY&f zKCrV{UgRAiNU*=+Uw%gjIQhTAC@67m)6(_D+N>)(^gK74F%M2NUpWpho}aq|Kxh$3 zz#DWOmQV4Lg&}`XTU41Z|P~5;wN2c?2L{a=)Xi~!m#*=22c~&AW zgG#yc!_p##fI&E{xQD9l#^x|9`wSyCMxXe<3^kDIkS0N>=oAz7b`@M>aT?e$IGZR; zS;I{gnr4cS^u$#>D(sjkh^T6_$s=*o%vNLC5+6J=HA$&0v6(Y1lm|RDn&v|^CTV{= zjVrg_S}WZ|k=zzp>DX08AtfT@LhW&}!rv^);ds7|mKc5^zge_Li>FTNFoA8dbk@K$ zuuzmDQRL1leikp%m}2_`A7*7=1p2!HBlj0KjPC|WT?5{_aa%}rQ+9MqcfXI0NtjvXz1U)|H>0{6^JpHspI4MfXjV%1Tc1O!tdvd{!IpO+@ z!nh()i-J3`AXow^MP!oVLVhVW&!CDaQxlD9b|Zsc%IzsZ@d~OfMvTFXoEQg9Nj|_L zI+^=(GK9!FGck+y8!KF!nzw8ZCX>?kQr=p@7EL_^;2Mlu1e7@ixfZQ#pqpyCJ```(m;la2NpJNoLQR};i4E;hd+|QBL@GdQy(Cc zTSgZ)4O~hXj86x<7&ho5ePzDrVD`XL7{7PjjNM1|6d5>*1hFPY!E(XDMA+AS;_%E~ z(dOs)vy29&I`5_yEw0x{8Adg%wvmoW&Q;x?5`HJFB@KtmS+o0ZFkE@f)v>YYh-z&m z#>ze?@JK4oE7kFRFD%MPC@x$^p{aW}*CH9Y_(oJ~St#(2)4e-b34D>VG6giMGFA83 zpZTHM2I*c8HE}5G;?Y7RXMA2k{Y?RxHb2 zZFQv?!*Kr_q;jt3`{?B5Wf}_a7`roT&m1BN9{;5Vqo6JPh*gnN(gj}#=A$-F(SRJj zUih_ce0f%K19VLXi5(VBGOFbc(YF zLvvOJl+W<}>_6_4O?LhD>MRGlrk;~J{S#Q;Q9F^;Cu@>EgZAH=-5fp02(VND(v#7n zK-`CfxEdonk!!65?3Ry(s$=|CvNV}u$5YpUf?9kZl8h@M!AMR7RG<9#=`_@qF@})d ztJDH>=F!5I+h!4#^DN6C$pd6^)_;0Bz7|#^edb9_qFg&eI}x{Roovml5^Yf5;=ehZ zGqz-x{I`J$ejkmGTFipKrUbv-+1S_Yga=)I2ZsO16_ye@!%&Op^6;#*Bm;=I^#F;? z27Sz-pXm4x-ykSW*3`)y4$89wy6dNOP$(@VYuPfb97XPDTY2FE{Z+{6=}LLA23mAc zskjZJ05>b)I7^SfVc)LnKW(&*(kP*jBnj>jtph`ZD@&30362cnQpZW8juUWcDnghc zy|tN1T6m?R7E8iyrL%)53`ymXX~_;#r${G`4Q(&7=m7b#jN%wdLlS0lb~r9RMdSuU zJ{~>>zGA5N`^QmrzaqDJ(=9y*?@HZyE!yLFONJO!8q5Up#2v>fR6CkquE$PEcvw5q zC8FZX!15JgSn{Gqft&>A9r0e#be^C<%)psE*nyW^e>tsc8s4Q}OIm})rOhuc{3o)g1r>Q^w5mas) zDlZQyjQefhl0PmH%cK05*&v{-M1QCiK=rAP%c#pdCq_StgDW}mmw$S&K6ASE=`u4+ z5wcmtrP27nAlQCc4qazffZoFV7*l2=Va}SVJD6CgRY^=5Ul=VYLGqR7H^LHA;H^1g}ekn=4K8SPRCT+pel*@jUXnLz+AIePjz@mUsslCN2 z({jl?BWf&DS+FlE5Xwp%5zXC7{!C=k9oQLP5B;sLQxd`pg+B@qPRqZ6FU(k~QkQu{ zF~5P=kLhs+D}8qqa|CQo2=cv$wkqAzBRmz_HL9(HRBj&73T@+B{(zZahlkkJ>EQmQ zenp59dy+L;sSWYde!z_W+I~-+2Xnm;c;wI_wH=RTgxpMlCW@;Us*0}L74J#E z8XbDWJGpBscw?W$&ZxZNxUq(*DKDwNzW7_}AIw$HF6Ix|;AJ3t6lN=v(c9=?n9;Y0 zK9A0uW4Ib9|Mp-itnzS#5in=Ny+XhGO8#(1_H4%Z6yEBciBiHfn*h;^r9gWb^$UB4 zJtN8^++GfT`1!WfQt#3sXGi-p<~gIVdMM<#ZZ0e_kdPG%Q5s20NNt3Jj^t$(?5cJ$ zGZ#FT(Lt>-0fP4b5V3az4_byF12k%}Spc$WsRydi&H|9H5u1RbfPC#lq=z#a9W(r1 z!*}KST!Yhsem0tO#r!z`znSL-=NnP~f(pw-sE+Z$e7i7t9nBP^5ts1~WFmW+j+<@7 zIh@^zKO{1%Lpx^$w8-S+T_59v;%N;EZtJzcfN%&@(Ux5 z@YzX^MwbbXESD*d(&qT7-eOHD6iaH-^N>p2sVdq&(`C$;?#mgBANIc5$r| z^A$r)@c{Z}N%sbfo?T`tTHz9-YpiMW?6>kr&W9t$Cuk{q^g1<$I~L zo++o2!!$;|U93cI#p4hyc!_Mv2QKXxv419}Ej#w#%N+YIBDdnn8;35!f2QZkUG?8O zpP47Wf9rnoI^^!9!dy~XsZ&!DU4bVTAi3Fc<9$_krGR&3TI=Az9uMgYU5dd~ksx+} zP+bs9y+NgEL>c@l>H1R%@>5SWg2k&@QZL(qNUI4XwDl6(=!Q^U%o984{|0e|mR$p+ z9BcwttR#7?As?@Q{+j?K6H7R71PuiA^Dl$=f47nUKL|koCwutc_P<-m{|Al3C~o7w z=4S=}s5LcJFT1zjS)+10X_r$74`K78pz!nGGH%JV%w75!YSIt#hT7}}K>+@{{a+Im z5p#6%^X*txY?}|T17xWW*sa^?G2QHt#@tlcw0GIcy;|NR2vaCBDvn=`h)1il7E5Rx z%)mA4$`$OZx)NF5vXZnaJ1)*cA6ryx6Ll~t!LzhxvcTedxT;>JS&e=?-&DXUPaQ2~ zH*69ezE`hgV{K-|0z|m~ld}=X^-Ob={wpex&}*+Rz{gx)G}gn!C_VN{UN=>^EV=Xc zr$-HO09cW&p4^M}V3yBjTP_xrVcc8iU_^Y-JD~(bgw*@GXGB1gYKz5DWO+O`>})|N zWrC)MR93yA)3{&27-M)TJB6Ml3~?zZg#mYsF=#OSTaw&K z@hBftpt+2l@)YK@|3DvTjl(8wZtpLp9Ik!6G$CSL_idZ$Ti?R)4toe8bb)l|)lNb}?K;O2K9vyn1QG zd=v#y-Ld49UVkmfRU>Egc+(Y$^-;6vW;3Lcu*6~etz}0|@+b|+!UCal)DEYGLbHWJ zll5Wi^$Y<6@S%^y%hdjRh6&{!z1Py|lZ|q&Wub3l41uN2zEF8E&5H5?PL*&V}?*a}Lp% zCYi{ghjpRNT^^B+_U59No50Ghih5qn(W5`RkrsDWr{~A1dgtv{sRkH4RU2^A{jb&0 zxVRnrm|u<;$iI;M6A>$POP)TWGU-gSjAERk*EGmVT(aw$!XUSe~7Ql-oRA54^4V(JWS6Q1mG?!vZ zx+pE!FEtvqr|Xrcb3oR`%LHFLmU_&{=p%mGy6MRe2Yz_5WJ8p@IgU2 zdVvvhhQtiQkChK%*&PsiPCBL9oDOoJX8!$S(V>R}+1M}wzK*U*A{KJ`r=lM;mPrKU zQDqqN(W*u-5-?$(SIk<6A0E}34y&@-IVC%S!a1F4kz<3bIKjlyD)ooO_7ftl%S_(6w`!vX&1PZ!K`@D@L6JR)6zO@Dl!YF{RY}d3HZ7?Q5E>w=$ ze)H_)48Ds*Ov4?zoGb2fe3}{!5Ooc|KCIni1o)(Gj+CO?`*7jsV`hIv@8J(22o4Q? zu?Bvi)zDG(me?7XKeL|iF9ZRgZdT*}Ffsl62Cu;{Gv9j6dO zPt*H2GqC)-C`V`ceuu=tM{7!2yTEj=*5+T~5DYiZ)Hy)*PARYI6R2lZXoOj;v8M4W z*O-NX(7_~Q&A3>Oaw&1lBH_H%SwmISX-i3)HfHvBOeVwTT{LUM3}ZuZmg<(>)KE;d zbs2!0v6>J;1nQ0UJkUxnkE@Ibi~Q}M=-=Rk;hcOnxO$luOKEVxZc|!XECgex(2`}T z3Y;Q_6rL)e+SrOZhQj5_e}Lv>w7n*Pep$yWZNQl>ubBgb_NIWWDn3kNpn+MPQXV;8 zV|_Ba5jsQ(w&Ey^IM|@|y!AqcJ#3m0#Q6_qvgCG~eoF#mnGmbO(;DP+bW%_aOs1R_ z@9p#7X2UA^--#Nwx_Hvk2l1`eO{P*#j@q2UELtH|Uh6hxR`h_847wIJo0=5CQQ`6it|%a-I$^&a@we1rc&*;QIu5Ck^?) zx*5eSd*mG#=6Hi(5!;5uUi&{HfnT1S8X-)?gE5CZ6KWoqM5|CyrULmuFBKOU8SOp* z{IB1$OCcq`S-k*xs;4fmhKsIGZ;GYAY*%(@875NxhMq|j*m4CNLI(Vho|N|F);!E0cS5y^$H^Izje?z}oTgyr`9x9G&rlJZw&uqIoBMtz zzhU0(9;w02?m#0!)cFi*r+8YvooQ;(s2lLVvyLqAE%Xqe!vtWbIs!l1Bpp(FIht-Z zPn#CN-2C|J*GhA2fuHqYQ2mJiXlGTzD}mkr2;ia8Wp}h^;OS7+N^Mw|en!1${vN6 z-x{8N*4UekA~`IV2&K-GzhAqau|}d*pEQ$1MH$cFi03OG^1NetZ_jW^STaEzr&Xho zB452St%v3ez2#TFm~`gZh$vi=in+y2d!z<{OZ~Kty-5bQ;0O=k_ESi8Nx9{*T`LJy6jqR>&|+>OZ;+=0hA04 zE25t^sE9HG)3^KKR_A5WDkqispweP9!I-@dCO&N!JrD@i{WBHnfQ z95o8;d$`AFnca3;N-0iX-CmbbAp5yQ!GoH;h7Cn?m{ammZJI8igP{U73lFnl2&gCs zqJ4(Vo~^j`{zOAzScL5B_Sm?Mjtek1d(A6X5ObcZi$;aOYy|g$}BY z$GEP3#i60Ju_&3SHzryH!gUFwC9-295u??cf+aYRQ1$+!rc#42YNattd6mZEFI@?C zqFM>6+zxEunIHDZ>{Z15u##>N(28Dw!>G(k*dB{NHvip@aP}f`@=Q;!o;zRMWo{Cx zo?kyzh8n7#f1g0&g>Cd>O-2g?uPwy8sy8hZbHSsXPmU;@l=HL=zm7mN(=@*|D$i+u zs~TllkCTvD$f&-#b9B?}#Lg*-ibK13R_a$RyoN3m5`10tdhAq{+VW)K#Bht-ra1*J z+n$N%V>u0rVtx`aKJDwXXrxaD7nS<>$=c82v7@KVx^S@vT;h=SZE37K>iahpx3;VDzEr9GY=2(%uaqM;^76eSP0QLzo4sI z>p_Eei*T$K;|qK`sq;?Hesp}(@VvX2Q4sAMYAJ}b&d$htDMC{FG-$o4k9ApECi1$a zXdamjiOGKHBh(4M<3(2x6n-CrmZMCknkQxdSS!qlis#I}btfX;J`JU3RlvtLdrymP zG0ZzrsGXVFiq+Wk1=BFay&9ZiCE#(`h~CL+c-Hs@iGTU@YxM%vlg;)`Tf~IknA^02 zXkN#Txo6aR{j$wP5T#|UH#5AP2{rSY8p?jKFv zG3kn3y`FaV!*Jq%m39_TQEhD>M@l*bhEPGe1{ft3q#K5AknT=F2_=T^l#ou5ln@D# z5Tzs(kRG@qNDa~HLNvfv7Z0g=bSlb?`QAx|Gfoni|iHJ%K0cy z;~Nsaa+{8HP_qrb{nj+xzkdYhSI@W4N_1`z(eSGIkbDP)!Ko|M%}Rqp(~KI2hl~eE zvJ!j4m6iwMgKy>fkCLC)`M$z9EV}B+sq1}}kVf$(ig0pWTY?rHz1Sm=4srTGNb^JG z=2$9wz-C@aZZZ2!HY#HNejqZRmE=pN(D$Kui$NpfhU`!y_s{@MIxiJdHb1|{6xb`> zE74_@QtgtG{4=3P1$^vn&m}7Aw8!1DnT$2thO#~44wl(N#ao8S0@t@m+Z!KD2CfK; z)n5DAPKV_etmH1aLDK$?`;sL91iVt$D z*SG}=-LIAg(*+JON!-5ivqOMQ1S!OQUgHglDsKik&Mwg;vva523`JwQH6SRz9eTY# zTIi23145~kc3r1mSWC_RzD%hs$S#!pkI9!BU80jJCJcwo*FZolQG$q`8C1d9pP@ND zG^&-ZraIvhg_FDVSfKGwkcI=avIan%2sK4coUs~Nr8jC*&!G0#?}_^s3r-c}-uAqi zM-Lw>Y}I``T;IS%Y|qH;s{F*ZefM!4{I5awr!K+T@uPd*Vu*iPWI}>(-D{zxsN>LG z=@747a_Rb2>q?y8xYf?dq2HM5tFO8Y5e4N;Y=xy8yAhI zsm>oy%R5;7)7T3V_b2%`aH^tNlsQpFxIFW#iV#8?{6{^cGr{A0@1bA)|K z>MMTuZD(pd2t|7vmHtywGXb%%=)S<`OG~}U+jm#xd%H8 z$v8-C%F?ah3$;hn?{G3(LT!SgvCVi$vwsZssAQvUwT`Q%qSw!LSd!(I!64w1=%Sc1Mck)q1@pZ@)=SY zoX}d+L3-RA|c?G3_BQNm&( z!i$AZ7cI(z7q|e9VM##6T3Xorj1JG(9os$;(I$y%mBy(#8{|3l4|x*oBAQL^XhZ0g zy1FR1teRrpKq{uLAibTLx#n({qwjlkOvR{OdSAeT5ah4-sNN)n4Clg1T9lzF)&yj; zyal1%+s4n1IG;^VPWJ;#olpk8Z42Gj-tjFeQ&PlxB)`oCNoUYKj4U$AeG8rYiD{pK zndDf&2;2;)D|KvOZP+e7fcPU9k4M2sfhr@vC~Ly0?S-4dz)ZGAYpCsAhChgbxLd4g zhTrbIPkO5SEp_kD>Ha0m12h5n3s;mE8kn515&nzSf+^D= zyE{JnJ;43l&BH55CL<=W%CF;6iUI)V5C*6!`**KqvzR2=Fj*3Y4`HYwx}TYD445(K z-QtXwtL?m*(F=LVH*H4oM>dXHBW=38q_dZ-_Vr&qpEPxd9Fs95P5W~@Z|Rt+WZP6l zPSQ}~Dh4V?Pp1g&Hk*Px?lm16C@X6M29Vrk%Rw@E||E-v~$ zb_E~{z<}#8i`Mx9mkqtd#Z1lZ-E_J8I+2oumc#x1)jdvh{W76NKm6x-RYpM~v!P8$ zw3e|YVf|}Hse9~oC@N7^j}Fi$hNpyaYnu1}bdXsD=^oI*%WKvbme|BI}$G3>smu#6y)ls|j? zF7Bhu9Z)j)C;3cZb+I>0stSK^WLOYV^U{pUYkgv>?+Nt^5j*CUB=eGw-CvU&40>y~ zGoHLXxY^7k5Xgv62{iQy|5jJQuq0|LU`}lE@flQ2Z*Zn*VWcQjm4FTb>LSVox^S4q zLn`LfS@mrjKCmg$nb^af?d?0&$aX6#2u(JyzIJvuJ*lwPrh|0~aEnSACCTezSdG%h zmSQg`17j@$Iq)r1&?+eR@1nlX|H`<}_!?BQSF&N+QQnvEAqZe+mIFui!0V49R?|9*$ zv!K1A01{8xq;L()Tv*Qk0-$Oj6+vCT*TUD{HvxO@3JjxBwM!4g3ydy&eaJw4CoQBF zJtULJ!YxgNR7_Ls%LmogyI7uIs=!B&?=MYY^yX+v;j@D_xGeZg>eZk0C;4e|HRNSi z6KlD9>q=3v-$4Zik&^ZDhNm1X)+7LCH1k!s+T3tn zUn@={1U&NJLq@K?~w|(=Y<4W{ucX}FdRr6pLw(l2$iK)At%t3gYBMlJz#(K0Nqm;=KAML!&MMSNz=%k=j*zh77r34Rs37iCY` z=_kva_41bdrj(b=4Wc5MO0~q^z#pIWJ>)vDSgIQF=3JVJe1iDy%h)8oNy{s_r&;m` zL{DYKSB_5xRb9xKNOS{qAY3qv5sSXVrrf%~*q5HO|CQ&lbKMePa$M5D{vlJcoGrCZ zD?fKbZN$6rWwz)w7`9h4DAmh1ij2}EO|bO#A9L0_RW6l*$sPPUJrUbhLC75L9%W5iO$Iw5~Yut-qBeu~hF|xD7-eQ%l z412vpq_;t%^F*pYDk%Q35c-erK|6Ve=FxQbAv~ikZ4c9$Y4;ee#ciOD9{yRqf55Qk zumv}#+JciT|Gj$uFOxBUze)=?l{B}qaC0_7m`t82<$K53!4Xvi9Tr)ADp3Off?O8o zVDG0Yx|tfn@r((m?Nxrh(b0DGjg)$;DfO&$6uY;4&F!4jnxkhP}Y3x zS?WFFt>=HWzqlQhffVfvM$Ta8Sg*r3j!Eo&rUOW7SCL2~lG7<+XZ;+{&8h5g8ElI+P>>yR2U%S93NN!Xhm|C682t6ysH-=o1=Bd*N*VlnG%l+KZFtjG`UkL;%65qn0UYQ`h zh0{9jDQx(`aBe7J0Aj3Z)4}`A|4OMM0a;?{j}qkYwi)~O8$9D}ITiMH2buiU>ixYp zhL${nwj6X($*OwmpVG`y5b6v45tX*J8?og}Qju6eJ9H}`X87iEd%BUo7<`2q(HJx+ zMR}d-J4oAf{V1W^a2~`M-YAdZ81dd4o6NPO{cmZaAS@RS4ir#Sr zfFZO-VIL|VN<%nEXr2` z$0FK2L#8O_f1w~c@G70JrB@N}r(gJ!Vmkk6{r68w!o$qO?HrFcjeU0_3F5;*!E2%( zTx>4?gP8w z1B?3UVZmz^%d_dIps>>0{cB~mp3{9UoPR6uQFecVq&} zY{ebB?AlPAD_}(ll{fK99;Wh1cgRbnw)maD^F>*J!R}eHM*W0VYN1TADWMy9H=$00 z5bHY${oDgwX7(W9LZw?}{!8(_{JB~Xkje6{0x4fgC4kUmpfJ+LT1DYD*TWu4#h{Y7 zFLronmc=hS=W=j1ar3r1JNjQoWo2hMWsqW*e?TF%#&{GpsaLp}iN~$)ar+7Ti}E&X z-nq~+Gkp(`qF0F_4A22>VZn-x>I$?PDZSeG8h_ifoWf^DxIb5%T7UytYo3}F|4#RC zUHpg$=)qVqD~=m(!~?XwocuxU1u}9qhhM7d^eqmJPi_e-!IO`*{u7A zbu*?L$Mbj-X9n3G2>+Kc#l`@d8}Xb9{l*IN{#M*d;s+3Pdr8FO$EBELR=8{ zd?LJbSv9fI`{OqTH)5{b?WulgMb)psp+W|@cSp=jtl-&5C}9lw@*0H+gEW(}mAWNz zf{~U;;N}|wdSaphgqnH{FWUy!{y3^=AC*c?RJ5Eb<^ zCgH_v7^axIUVmHSFL^zlj2R$zow$|y#7>%#U7d#Vp_ezcp3lefMyd5ES=q$>4pWyA zp_Zso^^NP~lu2=S6nD(3Z5u=Uy&B&F1i$J*3;3KhEkD_lgscHGR*;T;U!9vgQa(hI}oh9IzEf_PU_8F+i77t-~gDX z490Sb)LyVZmf18N6w{+37$aO<2!Av0 ztLaPOv^J<2@p{WnMiDudoghX_`luFZt_4eNU}*~cF5i%eEcNLs;D>QVIwr8mH;=dc z09`}JV;aaF;13@&iS(w>Jc=k~|d_1hcpM(l|O zu>!@}me%isTT$xT#hNUvh(ATd0wT4fbv=6htcHNEZIw9%E6wlYmwfu2{j0kh1y=$;Yf!|NldgB9ul zB{dbE&LfRnr8ITm@;-68wo#VV?8lG3ed&9k1}QBS3}WGV9%26?A1rBkkDR9Z3o+g+ z)eQg8BY3y(Dh5&z?VLLNdDV`C=muUvCPpGg!oYxIgOI3^%4>5d7jTh~ni!Fg2;fhx z(*c%H6Je84kmQh;5tC3*l~7khLxK-e|Cz?FLh!yYe7g|*LwqU?2wv^_ZyKT$fYVkGJo@AK0$+ml?}zJeB~deT2WL1vz}dxB z)y??t!}%M@)u$_IyW~)6u1SttJ!awd6N5lx|xBrmyrBh>tb&D*=C+Z3nPfq$1%WgY0bY*?PZ#Hk|=xn zGM#0*w4CaB^y0G(J4q=;5NeM@m-P}#mv7QZNF)M!dK^w{mk_!n0`+Y3PQutu-%NBt zzgPXug?JLEbUL{e_dk;Vd896&yPe(hliVK!lj%5+@BKdcrEZ2Nc_*i@ve*2lB>u~{ zFozd2FM|_0+nAGR4TLNHanQn_Oeb!JrUcvzJ?7p9TTNB}ocO3j$7ij!li8#k6 z@2tSd1>K03K9A#_-MIq)S;T#oE^;>U$)&}okIvDf3lm?kI{d80$>~xKUoS!%q1Pi?WpsUUt(tI ztjNjY*y&Rm9(S(DC2GuPHBJs@5M{RGm`c1z<6nwyN^)rMo-AS{M2$oM9|y%fM|}G~ DHx0+F literal 0 HcmV?d00001 From bb2ea09fa6856f5e261107713b2c8b3deeb6b4b1 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Fri, 18 Apr 2025 20:08:11 +0900 Subject: [PATCH 04/25] =?UTF-8?q?=F0=9F=93=A6Chore:=20Test=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closed #24 --- .github/workflows/dev-deploy.yml | 2 +- build.gradle | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index afa2b0f..e36508b 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -25,7 +25,7 @@ jobs: run: chmod +x ./gradlew - name: Build Spring Boot (JAR) - run: ./gradlew bootJar + run: ./gradlew bootJar -Penv=test - name: Copy JAR to shared volume run: cp build/libs/*.jar /deploy/app.jar diff --git a/build.gradle b/build.gradle index 3520b4e..9c0c553 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.17.10' - + // build.gradle + if (project.hasProperty('env') && project.env == 'test') { + dependencies { + implementation 'org.springframework.boot:spring-boot-starter-actuator' + } + } } tasks.named('test') { From 6894f6ef5d15604e0796400ca6bb3fc13fde54b0 Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Tue, 22 Apr 2025 04:09:34 +0900 Subject: [PATCH 05/25] =?UTF-8?q?Feature/#16=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: 로그 기능 개발 * ♻️ Refactor: 로그 유틸 및 Enum 클래스 리팩토링 * ♻️ Refactor: 로그 유틸 클래스 도메인 객체 내부 리팩토링 * ♻️ Refactor: 로그 기능 전체 리팩토링 - 캡슐화 및 상수 활용 * 🐛 Fix: LoggerWithTraceId 객체 내부 StackTraceElement배열 수정 * 💄 Style: 로그 기능의 모든 클래스에 주석을 추가하였습니다. * ♻️ Refactor: 상수로 선언된 부분을 env 파일과 enum클래스로 분리하였습니다. --- .../yakplus/YakplusApplication.java | 4 +- .../common/configuration/LogbackConfig.java | 179 ++++++++++++++++++ .../common/configuration/WebConfig.java | 38 ++++ .../common/interceptor/LogInterceptor.java | 94 +++++++++ .../yakplus/common/util/log/LogLevel.java | 108 +++++++++++ .../yakplus/common/util/log/LogMessage.java | 22 +++ .../yakplus/common/util/log/LogUtil.java | 65 +++++++ .../common/util/log/LoggerWithTraceId.java | 125 ++++++++++++ .../yakplus/logtest/MyController.java | 48 +++++ .../yakplus/logtest/MyService.java | 54 ++++++ 10 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/interceptor/LogInterceptor.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogMessage.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogUtil.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java b/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java index 91ce47c..0e8d32a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java @@ -1,12 +1,14 @@ package com.likelion.backendplus4.yakplus; +import com.likelion.backendplus4.yakplus.common.configuration.LogbackConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class YakplusApplication { public static void main(String[] args) { + LogbackConfig logbackConfig = new LogbackConfig(); + logbackConfig.configure(); SpringApplication.run(YakplusApplication.class, args); - } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java new file mode 100644 index 0000000..5aaa270 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java @@ -0,0 +1,179 @@ +package com.likelion.backendplus4.yakplus.common.configuration; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.FileAppender; +import ch.qos.logback.core.rolling.TimeBasedRollingPolicy; +import ch.qos.logback.core.util.FileSize; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 로깅 설정을 위한 설정 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Configuration +public class LogbackConfig { + private static final String LOG_DIRECTORY = "logs"; + private static final String LOG_FILE_NAME = "like-lion.log"; + private static final String LOG_PATTERN = "%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n"; + private static final int MAX_HISTORY = 30; + private static final String TOTAL_SIZE_CAP = "1GB"; + + /** + * 로깅 설정을 초기화하는 메서드 + * + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @PostConstruct + public void configure() { + LoggerContext context = initializeLoggerContext(); + createLogDirectory(); + + ConsoleAppender consoleAppender = createConsoleAppender(context); + FileAppender fileAppender = createFileAppender(context); + + configureRootLogger(context, consoleAppender, fileAppender); + } + + /** + * LoggerContext를 초기화하는 메서드 + * + * @return LoggerContext 초기화된 로거 컨텍스트 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private LoggerContext initializeLoggerContext() { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + return context; + } + + /** + * 로그 디렉토리를 생성하는 메서드 + * + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private void createLogDirectory() { + Path logPath = Paths.get(LOG_DIRECTORY); + try { + if (!Files.exists(logPath)) { + Files.createDirectories(logPath); + } + } catch (Exception e) { + throw new RuntimeException("로그 디렉토리 생성 실패", e); + } + } + + /** + * 콘솔 어펜더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return ConsoleAppender 생성된 콘솔 어펜더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private ConsoleAppender createConsoleAppender(LoggerContext context) { + ConsoleAppender appender = new ConsoleAppender<>(); + appender.setContext(context); + appender.setEncoder(createEncoder(context)); + appender.start(); + return appender; + } + + /** + * 파일 어펜더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return FileAppender 생성된 파일 어펜더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private FileAppender createFileAppender(LoggerContext context) { + FileAppender appender = new FileAppender<>(); + appender.setContext(context); + appender.setFile(LOG_DIRECTORY + "/" + LOG_FILE_NAME); + appender.setAppend(true); + appender.setEncoder(createEncoder(context)); + + TimeBasedRollingPolicy rollingPolicy = createRollingPolicy(context, appender); + rollingPolicy.start(); + + appender.start(); + return appender; + } + + /** + * 패턴 레이아웃 인코더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return PatternLayoutEncoder 생성된 패턴 레이아웃 인코더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private PatternLayoutEncoder createEncoder(LoggerContext context) { + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern(LOG_PATTERN); + encoder.start(); + return encoder; + } + + /** + * 롤링 정책을 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @param parent FileAppender 부모 파일 어펜더 + * @return TimeBasedRollingPolicy 생성된 롤링 정책 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private TimeBasedRollingPolicy createRollingPolicy(LoggerContext context, FileAppender parent) { + TimeBasedRollingPolicy policy = new TimeBasedRollingPolicy<>(); + policy.setContext(context); + policy.setParent(parent); + policy.setFileNamePattern(LOG_DIRECTORY + "/" + LOG_FILE_NAME.replace(".log", ".%d{yyyy-MM-dd}.log")); + policy.setMaxHistory(MAX_HISTORY); + policy.setTotalSizeCap(FileSize.valueOf(TOTAL_SIZE_CAP)); + return policy; + } + + /** + * 루트 로거를 설정하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @param consoleAppender ConsoleAppender 콘솔 어펜더 + * @param fileAppender FileAppender 파일 어펜더 + * @author 정안식 + * @since 2025-04-16 + */ + private void configureRootLogger(LoggerContext context, ConsoleAppender consoleAppender, FileAppender fileAppender) { + Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + if (logger instanceof ch.qos.logback.classic.Logger) { + ch.qos.logback.classic.Logger rootLogger = (ch.qos.logback.classic.Logger) logger; + rootLogger.setLevel(Level.INFO); + rootLogger.addAppender(consoleAppender); + rootLogger.addAppender(fileAppender); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java new file mode 100644 index 0000000..c45cae7 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java @@ -0,0 +1,38 @@ +package com.likelion.backendplus4.yakplus.common.configuration; + +import com.likelion.backendplus4.yakplus.common.interceptor.LogInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 웹 설정을 위한 설정 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private static final String ALL_PATTERN = "/**"; + + private LogInterceptor logInterceptor; + + /** + * 인터셉터를 등록하는 메서드 + * + * @param registry InterceptorRegistry 인터셉터 레지스트리 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 모든 요청에 대해 LogInterceptor를 적용 + registry.addInterceptor(logInterceptor) + .addPathPatterns(ALL_PATTERN); // 모든 URL 패턴에 적용 + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/interceptor/LogInterceptor.java b/src/main/java/com/likelion/backendplus4/yakplus/common/interceptor/LogInterceptor.java new file mode 100644 index 0000000..34592ab --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/interceptor/LogInterceptor.java @@ -0,0 +1,94 @@ +package com.likelion.backendplus4.yakplus.common.interceptor; + +import com.likelion.backendplus4.yakplus.common.util.log.LogMessage; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +/** + * 로깅을 위한 인터셉터 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Component +public class LogInterceptor implements HandlerInterceptor { + + /** + * 요청 처리 전에 실행되는 메서드 + * + * @param request HttpServletRequest 요청 객체 + * @param response HttpServletResponse 응답 객체 + * @param handler Object 핸들러 객체 + * @return boolean 처리 계속 여부 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, Object handler) { + String traceId = generateTraceId(); + setTraceId(traceId); + log("TraceId 생성 성공 - " + traceId); + return true; + } + + /** + * 요청 처리가 완료된 후 실행되는 메서드 + * + * @param request HttpServletRequest 요청 객체 + * @param response HttpServletResponse 응답 객체 + * @param handler Object 핸들러 객체 + * @param ex Exception 예외 객체 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @Override + public void afterCompletion(HttpServletRequest request, + HttpServletResponse response, Object handler, Exception ex) { + clearTraceId(); + } + + /** + * TraceId를 생성하는 메서드 + * + * @return String 생성된 TraceId + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private String generateTraceId() { + return UUID.randomUUID().toString(); + } + + /** + * TraceId를 설정하는 메서드 + * + * @param traceId String 설정할 TraceId + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private void setTraceId(String traceId) { + MDC.put(LogMessage.TRACE_ID.getMessage(), traceId); + } + + /** + * TraceId를 제거하는 메서드 + * + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private void clearTraceId() { + MDC.clear(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java new file mode 100644 index 0000000..fe2084f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java @@ -0,0 +1,108 @@ +package com.likelion.backendplus4.yakplus.common.util.log; + +import org.slf4j.Logger; + +/** + * 로그 레벨을 정의하는 열거형 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +public enum LogLevel { + /** + * INFO 레벨 로그 + */ + INFO { + @Override + public void log(Logger logger, String traceId, String message) { + logMessage(logger::info, traceId, message); + } + }, + /** + * DEBUG 레벨 로그 + */ + DEBUG { + @Override + public void log(Logger logger, String traceId, String message) { + logMessage(logger::debug, traceId, message); + } + }, + /** + * ERROR 레벨 로그 + */ + ERROR { + @Override + public void log(Logger logger, String traceId, String message) { + logMessage(logger::error, traceId, message); + } + + @Override + public void log(Logger logger, String traceId, String message, Throwable t) { + logger.error(formatMessage(traceId, message), t); + } + }; + + /** + * 로거 함수 인터페이스 + */ + @FunctionalInterface + private interface LoggerFunction { + void log(String message); + } + + /** + * 로그 메시지를 기록하는 메서드 + * + * @param loggerFunction LoggerFunction 로거 함수 + * @param traceId String 추적 ID + * @param message String 로그 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static void logMessage(LoggerFunction loggerFunction, String traceId, String message) { + loggerFunction.log(formatMessage(traceId, message)); + } + + /** + * 로그 메시지를 포맷팅하는 메서드 + * + * @param traceId String 추적 ID + * @param message String 로그 메시지 + * @return String 포맷팅된 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static String formatMessage(String traceId, String message) { + return String.format("TraceId: %s - %s", traceId, message); + } + + /** + * 로그를 기록하는 추상 메서드 + * + * @param logger Logger 로거 객체 + * @param traceId String 추적 ID + * @param message String 로그 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public abstract void log(Logger logger, String traceId, String message); + + /** + * 예외와 함께 로그를 기록하는 메서드 + * + * @param logger Logger 로거 객체 + * @param traceId String 추적 ID + * @param message String 로그 메시지 + * @param t Throwable 예외 객체 + * @throws UnsupportedOperationException 지원하지 않는 로그 레벨일 경우 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public void log(Logger logger, String traceId, String message, Throwable t) { + throw new UnsupportedOperationException("이 로그 레벨은 예외 로깅을 지원하지 않습니다."); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogMessage.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogMessage.java new file mode 100644 index 0000000..ac1b7a4 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogMessage.java @@ -0,0 +1,22 @@ +package com.likelion.backendplus4.yakplus.common.util.log; + +import lombok.Getter; + +@Getter +public enum LogMessage { + DATA_PROCESSING_START("데이터 처리를 시작합니다"), + DATA_PROCESSING_SUCCESS("데이터 처리가 성공적으로 완료되었습니다"), + DATA_PROCESSING_ERROR("데이터 처리 중 오류가 발생했습니다"), + SERVICE_DATA_PROCESSING_START("Service: 데이터 처리를 시작합니다"), + SERVICE_DATA_PROCESSING_SUCCESS("Service: 데이터 처리가 완료되었습니다"), + SERVICE_DATA_PROCESSING_ERROR("Service: 데이터 처리 중 오류가 발생했습니다"), + PROCESSED_DATA_RESULT("처리된 데이터"), + TRACE_ID("traceId"); + + private final String message; + + LogMessage(String message) { + this.message = message; + } + +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogUtil.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogUtil.java new file mode 100644 index 0000000..92c6880 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogUtil.java @@ -0,0 +1,65 @@ +package com.likelion.backendplus4.yakplus.common.util.log; + +/** + * 로깅 유틸리티 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +public class LogUtil { + + /** + * INFO 레벨로 로그를 기록하는 메서드 + * + * @param message String 로그 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public static void log(String message) { + logWithLevel(LogLevel.INFO, message); + } + + /** + * 지정된 레벨로 로그를 기록하는 메서드 + * + * @param level LogLevel 로그 레벨 + * @param message String 로그 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public static void log(LogLevel level, String message) { + logWithLevel(level, message); + } + + /** + * 예외와 함께 지정된 레벨로 로그를 기록하는 메서드 + * + * @param level LogLevel 로그 레벨 + * @param message String 로그 메시지 + * @param t Throwable 예외 객체 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public static void log(LogLevel level, String message, Throwable t) { + LoggerWithTraceId loggerWithTraceId = LoggerWithTraceId.create(); + level.log(loggerWithTraceId.getLogger(), loggerWithTraceId.getTraceId(), message, t); + } + + /** + * 내부적으로 로그를 기록하는 private 메서드 + * + * @param level LogLevel 로그 레벨 + * @param message String 로그 메시지 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static void logWithLevel(LogLevel level, String message) { + LoggerWithTraceId loggerWithTraceId = LoggerWithTraceId.create(); + level.log(loggerWithTraceId.getLogger(), loggerWithTraceId.getTraceId(), message); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java new file mode 100644 index 0000000..75d2e98 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java @@ -0,0 +1,125 @@ +package com.likelion.backendplus4.yakplus.common.util.log; + +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * TraceId를 포함한 로거 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Getter +public class LoggerWithTraceId { + private final Logger logger; + private final String traceId; + + /** + * LoggerWithTraceId 생성자 + * + * @param logger Logger 로거 객체 + * @param traceId String 추적 ID + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private LoggerWithTraceId(Logger logger, String traceId) { + this.logger = logger; + this.traceId = traceId; + } + + /** + * LoggerWithTraceId 인스턴스를 생성하는 팩토리 메서드 + * + * @return LoggerWithTraceId 생성된 로거 인스턴스 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public static LoggerWithTraceId create() { + String traceId = makeTraceId(); + Logger logger = makeLogger(); + return new LoggerWithTraceId(logger, traceId); + } + + /** + * 로거를 생성하는 메서드 + * + * @return Logger 생성된 로거 + * @throws IllegalStateException 로거 생성 실패 시 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static Logger makeLogger() { + String callingClassName = getCallingClassName(); + if (callingClassName.trim().isEmpty()) { + throw new IllegalStateException("호출 클래스명을 찾을 수 없습니다."); + } + + Logger logger = LoggerFactory.getLogger(callingClassName); + if (logger == null) { + throw new IllegalStateException(String.format("클래스 '%s'에 대한 Logger 생성 실패", callingClassName)); + } + return logger; + } + + /** + * TraceId를 생성하는 메서드 + * + * @return String 생성된 TraceId + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static String makeTraceId() { + String traceId = MDC.get("traceId"); + validateTraceId(traceId); + return traceId; + } + + /** + * TraceId를 검증하는 메서드 + * + * @param traceId String 검증할 TraceId + * @throws IllegalStateException 유효하지 않은 TraceId + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static void validateTraceId(String traceId) { + if (traceId == null) { + throw new IllegalStateException("TraceId가 null입니다. MDC에 traceId가 설정되어 있는지 확인하세요."); + } + if (traceId.trim().isEmpty()) { + throw new IllegalStateException("TraceId가 빈 문자열입니다. 유효한 traceId를 설정해주세요."); + } + } + + /** + * 호출한 클래스의 이름을 가져오는 메서드 + * + * @return String 호출한 클래스명 + * @throws IllegalStateException 스택 트레이스 관련 오류 발생 시 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private static String getCallingClassName() { + StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace(); + if (stackTraceElements.length == 0) { + throw new IllegalStateException("스택 트레이스가 비어 있습니다."); + } + if (stackTraceElements.length < 5) { + throw new IllegalStateException("스택 트레이스가 예상보다 짧습니다."); + } + + String className = stackTraceElements[6].getClassName(); + if (className.trim().isEmpty()) { + return "UnknownClass"; + } + return className; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java new file mode 100644 index 0000000..580fc12 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java @@ -0,0 +1,48 @@ +package com.likelion.backendplus4.yakplus.logtest; + +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.common.util.log.LogMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +/** + * 데이터 처리를 위한 컨트롤러 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class MyController { + + private final MyService myService; + + /** + * 데이터 처리 요청을 처리하는 메서드 + * + * @return ResponseEntity 처리 결과 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @GetMapping("/process") + public ResponseEntity process() { + log(LogLevel.INFO, LogMessage.DATA_PROCESSING_START.getMessage()); + + try { + String result = myService.processData(); + log(LogLevel.INFO, LogMessage.DATA_PROCESSING_SUCCESS.getMessage()); + return ResponseEntity.ok(result); + } catch (Exception e) { + log(LogLevel.ERROR, LogMessage.DATA_PROCESSING_ERROR.getMessage(), e); + return ResponseEntity.internalServerError() + .body(LogMessage.DATA_PROCESSING_ERROR.getMessage()); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java new file mode 100644 index 0000000..3480559 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java @@ -0,0 +1,54 @@ +package com.likelion.backendplus4.yakplus.logtest; + +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.common.util.log.LogMessage; +import org.springframework.stereotype.Service; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +/** + * 데이터 처리를 위한 서비스 클래스 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ +@Service +public class MyService { + + private static final long PROCESSING_DELAY = 1000L; + + /** + * 데이터를 처리하는 메서드 + * + * @return String 처리된 데이터 결과 + * @throws RuntimeException 처리 중 오류 발생 시 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + public String processData() { + log(LogLevel.INFO, LogMessage.SERVICE_DATA_PROCESSING_START.getMessage()); + + try { + simulateProcessing(); + log(LogLevel.INFO, LogMessage.SERVICE_DATA_PROCESSING_SUCCESS.getMessage()); + return LogMessage.PROCESSED_DATA_RESULT.getMessage(); + } catch (InterruptedException e) { + log(LogLevel.ERROR, LogMessage.SERVICE_DATA_PROCESSING_ERROR.getMessage(), e); + Thread.currentThread().interrupt(); + throw new RuntimeException(LogMessage.SERVICE_DATA_PROCESSING_ERROR.getMessage(), e); + } + } + + /** + * 처리 과정을 시뮬레이션하는 private 메서드 + * + * @throws InterruptedException 인터럽트 발생 시 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private void simulateProcessing() throws InterruptedException { + Thread.sleep(PROCESSING_DELAY); + } +} From 9d0551c9e79d13f625664353cb1d4e0b877f92ff Mon Sep 17 00:00:00 2001 From: HaechangLee <112938092+HaechangLee@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:39:02 +0900 Subject: [PATCH 06/25] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B3=B5=EA=B3=B5?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20API=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦Chore: API 응답 파싱을 위한 의존성 라이브러리, 환경변수 추가 * ✨Feat: API 요청용 객체 추가 * 📦 Chore: 주석 추가 * ✨ Feat: 의약품 상세정보 API 호출 개발 * ✨ Feat: 샘플 검색 데이터 추출 개발 * ✨ Feat: 공공데이터 API 파싱 개발 * ♻️ Refactor: 파싱을 위한 Wrappeer 클래스 정리 (인터페이스 추가) * ♻️ Refactor: 불필요한 생성자 삭제 * ♻️ Refactor: aricle parsing 가독성 개선 * 🐛 Fix: SectionWrapper parseElement 오타 수정 * ♻️ Refactor: 로직 수정 및 가독성 개선 * ♻️ Refactor: Paragraph 파싱 수정 및 헥사고날 아키텍처 적용 * ♻️ Refactor: 도메인 메소드 접근자 변경 * 📦 gitignore 수정 및 재 커밋 * ♻️ Refactor: 공공데이터 개요정보 API 파싱 수정 * ✨ Feat: API 전체 데이터 저장 * ✨ Feat: api 전체 데이터 저장 * 🐛 Fix: Xml 파서에 null 체크 로직 추가 * ✨ Feat: 공공데이터 전체 저장 기능 개발 * ♻️ Refactor: 전체 데이터 저장 함수 리팩토링 --------- Co-authored-by: HaechangLee <112938092+HaechangLee@users.noreply.github.com> * ♻️ Refactor: 패키지구조 개선 * 🐛 Fix: import에러 수정 --------- Co-authored-by: thelightway --- .gitignore | 2 +- build.gradle | 1 + .../in/DrugApprovalDetailScraperUseCase.java | 7 + .../port/out/DrugDetailRepositoryPort.java | 11 + .../service/DrugApprovalDetailScraper.java | 156 +++++++++++++ .../service/DrugImageGovScraper.java | 48 ++++ .../yakplus/domain/model/GovDrugDetail.java | 33 +++ .../out/GovDrugDetailRepositoryAdapter.java | 46 ++++ .../repository/ApiDataDrugImgRepo.java | 10 + .../GovDrungDetailJpaRepository.java | 8 + .../entity/ApiDataDrugImgEntity.java | 20 ++ .../entity/GovDrugDetailEntity.java | 89 +++++++ .../config/ApiRestTemplateConfig.java | 19 ++ .../presentation/batch/BatchConfig.java | 5 + .../presentation/batch/JobLauncher.java | 5 + .../controller/DragImageController.java | 19 ++ .../controller/DrugDetailController.java | 27 +++ .../support/api/ApiResponseMapper.java | 41 ++++ .../support/api/ApiUriCompBuilder.java | 100 ++++++++ .../support/parser/MaterialParser.java | 59 +++++ .../yakplus/support/parser/XMLParser.java | 217 ++++++++++++++++++ src/main/resources/application.yml | 15 +- 22 files changed, 935 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java diff --git a/.gitignore b/.gitignore index 8b64324..a2ba527 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ build/ *.iml *.iws *.ipr -out/ +/out/ .idea/**/ .idea_modules/ .idea/httpRequests diff --git a/build.gradle b/build.gradle index 9c0c553..ff144b2 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { // RDB runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml' // Elastic search implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java new file mode 100644 index 0000000..53a5bb2 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java @@ -0,0 +1,7 @@ +package com.likelion.backendplus4.yakplus.application.port.in; + +public interface DrugApprovalDetailScraperUseCase { + void requestUpdateRawData(); + + void requestUpdateAllRawData(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java new file mode 100644 index 0000000..bfe6c84 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java @@ -0,0 +1,11 @@ +package com.likelion.backendplus4.yakplus.application.port.out; + +import java.util.List; + +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + +public interface DrugDetailRepositoryPort { + + List findAll(String code); + void saveAllAndFlush(List entities); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java new file mode 100644 index 0000000..31fd972 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java @@ -0,0 +1,156 @@ +package com.likelion.backendplus4.yakplus.application.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.likelion.backendplus4.yakplus.support.api.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.support.api.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.support.parser.MaterialParser; +import com.likelion.backendplus4.yakplus.support.parser.XMLParser; +import com.likelion.backendplus4.yakplus.application.port.in.DrugApprovalDetailScraperUseCase; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.application.port.out.DrugDetailRepositoryPort; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DrugApprovalDetailScraper implements DrugApprovalDetailScraperUseCase { + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + private final ApiUriCompBuilder apiUriCompBuilder; + private final DrugDetailRepositoryPort drugDetailRepositoryPort; + + @Transactional + @Override + public void requestUpdateRawData() { + log.info("API 데이터 요청"); + String response = restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(1), String.class); + log.debug("API Response: {}", response); + + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List drugs = toListFromJson(items); + for (GovDrugDetailEntity drug : drugs) { + System.out.println(drug); + } + drugDetailRepositoryPort.saveAllAndFlush(drugs); + } + + + @Override + public void requestUpdateAllRawData() { + int pageNo = 1; + int receivedCount = 0; + int savedCountWithoutDuplicates = 0; + + String response = fetchPage(pageNo); + int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); + + while (hasMoreData(receivedCount, totalCount)) { + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List drugs = toListFromJson(items); + receivedCount += drugs.size(); + + // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) + int uniqueItems = deduplicateByItemSeq(drugs); + savedCountWithoutDuplicates += uniqueItems; + + drugDetailRepositoryPort.saveAllAndFlush(drugs); + + log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", + pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); + + response = fetchPage(++pageNo); + } + + } + + private List toListFromJson(JsonNode items) { + + log.info("items 약품 객체로 맵핑"); + try { + List apiDataDrugDetails = toApiDetails(items); + for (int i = 0; i < apiDataDrugDetails.size(); i++) { + GovDrugDetailEntity drugDetail = apiDataDrugDetails.get(i); + JsonNode item = items.get(i); + log.debug("item seq: " + item.get("ITEM_SEQ").asText()); + + String materialRawData = item.get("MATERIAL_NAME").asText(); + String materialInfo = MaterialParser.parseMaterial(materialRawData); + drugDetail.changeMaterialInfo(materialInfo); + + String efficacyXmlText = item.get("EE_DOC_DATA").asText(); + String efficacy = XMLParser.toJson(efficacyXmlText); + drugDetail.changeEfficacy(efficacy); + + String usageXmlText = items.get(i).get("UD_DOC_DATA").asText(); + String usages = XMLParser.toJson(usageXmlText); + drugDetail.changeUsage(usages); + + String precautionxmlText = items.get(i).get("NB_DOC_DATA").asText(); + String precautions = XMLParser.toJson(precautionxmlText); + drugDetail.changePrecaution(precautions); + } + return apiDataDrugDetails; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private List toApiDetails(JsonNode items) { + try { + return objectMapper.readValue(items.toString(), + new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + // private JsonNode toJsonFromXml(String usageXmlText) throws JsonProcessingException { + // XmlMapper xmlMapper = new XmlMapper(); + // + // JsonNode jsonNode = xmlMapper.readTree(usageXmlText) + // .path("SECTION") + // .path("ARTICLE"); + // return jsonNode; + // } + + // TODO: 추후 삭제 예정 + // private String replaceText(String text){ + // return text.replace("ᆞ ", "&") + // .replace("• ","") + // .replace("〜 ", "~"); + // } + + private int deduplicateByItemSeq(List drugs) { + // itemseq 기준으로 set에 저장 --> set은 중복 허용하지 않으므로 item seq 다 넣으면 알아서 중복 없이 저장됨 + Set uniqueItems = new HashSet<>(); + + for (GovDrugDetailEntity drug : drugs) { + uniqueItems.add(drug.getDrugId()); + } + return uniqueItems.size(); + } + + private String fetchPage(int pageNo) { + return restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(pageNo), String.class); + } + + private boolean hasMoreData(int receivedCount, int totalCount) { + return receivedCount < totalCount; + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java new file mode 100644 index 0000000..4b998a7 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java @@ -0,0 +1,48 @@ +package com.likelion.backendplus4.yakplus.application.service; + +import java.net.URI; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.support.api.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.support.api.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.ApiDataDrugImgRepo; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DrugImageGovScraper { + private final ApiUriCompBuilder uriCompBuilder; + private final RestTemplate restTemplate; + private final ApiDataDrugImgRepo imgRepo; + private final ObjectMapper objectMapper; + + @Transactional + public void getApiData(){ + log.info("의약품 개요 정보 API 호출 시작"); + + URI uriForImgApi = uriCompBuilder.getUriForImgApi(1); + + String response = restTemplate.getForObject(uriForImgApi, String.class); + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List imgDatas = null; + try { + imgDatas = objectMapper.readValue(items.toString(), + new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + imgRepo.saveAllAndFlush(imgDatas); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java new file mode 100644 index 0000000..8e99de9 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java @@ -0,0 +1,33 @@ +package com.likelion.backendplus4.yakplus.domain.model; + +import java.time.LocalDate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Builder; + +@Builder +public class GovDrugDetail { + private Long drugId; + private String drugName; + private String company; + private LocalDate permitDate; + private boolean isGeneral; + private String materialInfo; + private String storeMethod; + private String validTerm; + private String efficacy; + private String usage; + private String precaution; + + public JsonNode toJson(String json) { + try { + return new ObjectMapper().readValue(json, JsonNode.class); + } catch (JsonProcessingException e) { + //TODO 에러 로그 처리 필요합니다. + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java new file mode 100644 index 0000000..cc8928c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java @@ -0,0 +1,46 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.out; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.likelion.backendplus4.yakplus.application.port.out.DrugDetailRepositoryPort; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrungDetailJpaRepository; +import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GovDrugDetailRepositoryAdapter implements DrugDetailRepositoryPort { + private final GovDrungDetailJpaRepository govDrungDetailJpaRepository; + + @Override + public List findAll(String code) { + return govDrungDetailJpaRepository.findAll(); + } + + @Override + @Transactional + public void saveAllAndFlush(List entities) { + govDrungDetailJpaRepository.saveAllAndFlush(entities); + } + + public GovDrugDetail toDomainFromEntity(GovDrugDetailEntity detail){ + return GovDrugDetail.builder() + .drugId(detail.getDrugId()) + .drugName(detail.getDrugName()) + .company(detail.getCompany()) + .permitDate(detail.getPermitDate()) + .isGeneral(detail.isGeneral()) + .materialInfo(detail.getMaterialInfo()) + .storeMethod(detail.getStoreMethod()) + .validTerm(detail.getValidTerm()) + .efficacy(detail.getEfficacy()) + .usage(detail.getUsage()) + .precaution(detail.getPrecaution()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java new file mode 100644 index 0000000..c2c1593 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java @@ -0,0 +1,10 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; + +@Repository +public interface ApiDataDrugImgRepo extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java new file mode 100644 index 0000000..6871dc0 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + +public interface GovDrungDetailJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java new file mode 100644 index 0000000..5a13e32 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java @@ -0,0 +1,20 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.ToString; + +@Entity +@ToString +@Table(name="API_DATA_DRUG_IMG") +public class ApiDataDrugImgEntity { + @Id + @JsonProperty("ITEM_SEQ") + private Long seq; + + @JsonProperty("BIG_PRDT_IMG_URL") + private String imgUrl; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java new file mode 100644 index 0000000..925be23 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java @@ -0,0 +1,89 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.time.LocalDate; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Table(name = "gov_drug_detail") +public class GovDrugDetailEntity { + + @Id + @JsonProperty("ITEM_SEQ") + @Column( name= "ITEM_SEQ") + private Long drugId; + + @JsonProperty("ITEM_NAME") + @Column( name= "ITEM_NAME", columnDefinition = "TEXT") + private String drugName; + + @JsonProperty("ENTP_NAME") + @Column( name= "ENTP_NAME") + private String company; + + @JsonProperty("ITEM_PERMIT_DATE") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd") + @Column( name= "ITEM_PERMIT_DATE") + private LocalDate permitDate; + + @Column(name = "ETC_OTC_CODE") + private boolean isGeneral; + + @Column(name = "MATERIAL_NAME", columnDefinition = "JSON") + private String materialInfo; + + @JsonProperty("STORAGE_METHOD") + @Column(name = "STORAGE_METHOD", columnDefinition = "TEXT") + private String storeMethod; + + @JsonProperty("VALID_TERM") + @Column(name = "VALID_TERM") + private String validTerm; + + @Column(name = "EE_DOC_DATA", columnDefinition = "JSON") + private String efficacy; + + @Column(name = "UD_DOC_DATA", columnDefinition = "JSON") + private String usage; + + @Column(name = "NB_DOC_DATA", columnDefinition = "JSON") + private String precaution; + + @JsonCreator + public GovDrugDetailEntity(@JsonProperty("ETC_OTC_CODE") String drugType) { + this.isGeneral = !"전문의약품".equals(drugType); + } + + public void changeMaterialInfo(String materialInfo){ + this.materialInfo = materialInfo; + } + + public void changeUsage(String usage) { + this.usage = usage; + } + + public void changeEfficacy(String efficacy) { + this.efficacy = efficacy; + } + + public void changePrecaution(String precaution) { + this.precaution = precaution; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java new file mode 100644 index 0000000..d7cd39d --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * Api 요청을 보내기 위한 RestTemplate 빈 생성 + * + * @since 2025-04-15 + * @author 함예정 + */ +@Configuration +public class ApiRestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java new file mode 100644 index 0000000..df403e8 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.presentation.batch; + +public class BatchConfig { + //TODO: 추후 배치로 분리 +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java new file mode 100644 index 0000000..0490bdc --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.presentation.batch; + +public class JobLauncher { + //TODO: 추후 배치로 분리 +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java new file mode 100644 index 0000000..0fa6dca --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.presentation.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.backendplus4.yakplus.application.service.DrugImageGovScraper; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DragImageController { + private final DrugImageGovScraper imageScraper; + + @GetMapping("/gov/api/parser/image/start") + public void test(){ + imageScraper.getApiData(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java new file mode 100644 index 0000000..ee67a0c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java @@ -0,0 +1,27 @@ +package com.likelion.backendplus4.yakplus.presentation.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import com.likelion.backendplus4.yakplus.application.port.in.DrugApprovalDetailScraperUseCase; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class DrugDetailController { + private final DrugApprovalDetailScraperUseCase scraperUseCase; + + @GetMapping("/gov/api/parser/detail/start") + public ResponseEntity saveAPIData(){ + scraperUseCase.requestUpdateRawData(); + return ResponseEntity.ok().build(); + } + + @GetMapping("/gov/api/parser/detail/startAll") + public ResponseEntity saveAPIDataAll(){ + scraperUseCase.requestUpdateAllRawData(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java new file mode 100644 index 0000000..d198795 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java @@ -0,0 +1,41 @@ +package com.likelion.backendplus4.yakplus.support.api; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ApiResponseMapper { + + public static JsonNode getItemsFromResponse(String response) { + log.info("응답에서 items 값 추출"); + try { + return new ObjectMapper().readTree(response) + .path("body") + .path("items"); + } catch (JsonProcessingException e) { + log.error("items 추출 실패"); + //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 + throw new RuntimeException(e); + } + } + + public static int getTotalCountFromResponse(String response) { + log.info("응답에서 데이터 사이즈 추출"); + try { + return new ObjectMapper().readTree(response) + .path("body") + .path("totalCount") + .asInt(); + } catch (JsonProcessingException e) { + log.error("totalCount 추출 실패"); + //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java new file mode 100644 index 0000000..5b40d67 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java @@ -0,0 +1,100 @@ +package com.likelion.backendplus4.yakplus.support.api; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +/*** + * API 요청 URI 객체 생성 빌더 + * + * application.yml에서 주입되는 속성 값으로, + * API HOST, PATH를 확인해 URI 객체를 만듭니다. + * + * @since 2025-04-15 + * @author 함예정 + */ +@Component +public class ApiUriCompBuilder { + private final String SERVICE_KEY; + private final String HOST; + private final String API_DETAIL_PATH; + private final String API_IMG_PATH ; + private final String RESPONSE_TYPE; + + public ApiUriCompBuilder(@Value("${gov.host}") String host, + @Value("${gov.serviceKey}") String serviceKey, + @Value("${gov.path.detail}") String pathDetail, + @Value("${gov.path.img}") String pathImg, + @Value("${gov.type}") String type) { + this.HOST = host; + this.SERVICE_KEY = serviceKey; + this.API_DETAIL_PATH = pathDetail; + this.API_IMG_PATH = pathImg; + this.RESPONSE_TYPE = type; + } + + /*** + * 입력 받은 path를 반영해 URI 객체를 생성, 반환 + * + * @param path API 요청 경로 + * @return URI + * + * @since 2025-04-15 + * @author 함예정 + */ + private URI getUri(String path, int pageNo) { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(HOST) + .port(443) + .path(path) + .queryParam("serviceKey", SERVICE_KEY) + .queryParam("type", RESPONSE_TYPE) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", 100) + .build(true) + .toUri(); + } + + /*** + * 식품의약품안전처 의약품 제품 허가 상세 정보 URI 반환 + * @return URI 제품 허가 상세 정보 + * + * @since 2025-04-15 + * @author 함예정 + */ + public URI getUriForDetailApi(int pageNo) { + return getUri(API_DETAIL_PATH, pageNo); + } + + /*** + * 식품의약품안전처 의약품 제품 허가 목록 URI 반환 + * @return URI 제품 허가 목록 + * + * @since 2025-04-15 + * @author 함예정 + */ + public URI getUriForImgApi(int pageNo) { + return getUri(API_IMG_PATH, pageNo); + } + + // TODO 추후 삭제 + public URI getUriForImgApiBySeq(String seq) { + // 임시 URI + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(HOST) + .port(443) + .path(API_IMG_PATH) + .queryParam("serviceKey", SERVICE_KEY) + .queryParam("type", RESPONSE_TYPE) + .queryParam("numOfRows", 1) + .queryParam("prdlst_Stdr_code", seq) + .build(true) + .toUri(); + + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java b/src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java new file mode 100644 index 0000000..ff0f62a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java @@ -0,0 +1,59 @@ +package com.likelion.backendplus4.yakplus.support.parser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class MaterialParser { + public static String parseMaterial(String raw) throws Exception { + ObjectMapper result = new ObjectMapper(); + ArrayNode resultArray = result.createArrayNode(); + String[] blocks = splitBlock(raw); + parsingblocksAndPutArrayItem(blocks, resultArray); + return convertString(result, resultArray); + } + + private static void parsingblocksAndPutArrayItem(String[] blocks, ArrayNode resultArray) { + for (String block : blocks) { + block = block.trim(); + if (block.isEmpty()) { + continue; + } + String[] pairs = splitByPipe(block); + ObjectNode item = makeItem(pairs); + resultArray.add(item); + } + } + + private static String convertString(ObjectMapper objectMapper, ArrayNode arrayNode) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(arrayNode); + } catch (JsonProcessingException e) { + //TODO String 변환실패 + throw new RuntimeException(e); + } + } + + private static ObjectNode makeItem(String[] pairs) { + ObjectNode item = new ObjectMapper().createObjectNode(); + for (String pair : pairs) { + String[] kv = pair.split(":", 2); + String key = kv[0].trim(); + String value = ""; + if(kv.length == 2){ + value = kv[1].trim(); + } + item.put(key, value); + } + return item; + } + + private static String[] splitByPipe(String block) { + return block.split("\\|"); + } + + private static String[] splitBlock(String raw) { + return raw.split(";"); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java b/src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java new file mode 100644 index 0000000..e5f7614 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java @@ -0,0 +1,217 @@ +package com.likelion.backendplus4.yakplus.support.parser; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class XMLParser { + public static String toJson(String xml) { + + if(isXmlNull(xml)) { + return "{\"\": \"\"}"; + } + + Document doc = parseXmlString(xml); + Element root = doc.getDocumentElement(); + + List allSections = new ArrayList<>(); + List allArticles = new ArrayList<>(); + List allParagraphs = new ArrayList<>(); + + Map sectionMap = new HashMap<>(); + Map articleMap = new HashMap<>(); + + DocTag docTag = new DocTag(root, allSections); + parseSesctions(root, allSections, sectionMap); + parseArticles(root, allArticles, articleMap, sectionMap); + parseParagraph(root, allParagraphs, articleMap); + return convertJson(docTag); + } + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + + private static String convertJson(DocTag docTag) { + try { + return mapper.writeValueAsString(docTag); + //TODO: 예외처리 후 삭제 + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static void parseParagraph(Element root, List allParagraphs, Map articleMap) { + NodeList paraNodes = root.getElementsByTagName("PARAGRAPH"); + + if(paraNodes.getLength() != 0){ + for (int i = 0; i < paraNodes.getLength(); i++) { + Element paragraphElement = (Element) paraNodes.item(i); + ParagraphTag paragraphTag = new ParagraphTag(); + paragraphTag.tagName = paragraphElement.getAttribute("tagName"); + paragraphTag.textIndent = paragraphElement.getAttribute("textIndent"); + paragraphTag.marginLeft = paragraphElement.getAttribute("marginLeft"); + paragraphTag.text = paragraphElement.getTextContent().trim(); + + allParagraphs.add(paragraphTag); + mapSectionFromArticle(articleMap, paragraphTag, paragraphElement); + } + } + + } + + private static void parseArticles(Element root, List allArticles, + Map articleMap, + Map sectionMap) { + NodeList artNodes = root.getElementsByTagName("ARTICLE"); + if(artNodes.getLength() > 0) { + for (int i = 0; i < artNodes.getLength(); i++) { + Element artElement = (Element) artNodes.item(i); + ArticleTag articleTag = new ArticleTag(); + articleTag.title = artElement.getAttribute("title"); + articleTag.paragraphs = new ArrayList<>(); + + allArticles.add(articleTag); + articleMap.put(artElement, articleTag); + mapSectionFromArticle(sectionMap, articleTag, artElement); + } + } + + } + + private static void mapSectionFromArticle(Map map, Tags tags, Element element) { + Element parentElement = (Element) element.getParentNode(); + Tags parentTag = map.get(parentElement); + if (parentTag != null) { + parentTag.addTag(tags); + } + } + + private static void parseSesctions(Element root, List allSections, Map sectionMap) { + NodeList secNodes = root.getElementsByTagName("SECTION"); + + if(secNodes.getLength() > 0) { + for (int i = 0; i < secNodes.getLength(); i++) { + Element secEl = (Element) secNodes.item(i); + SectionTag secDto = new SectionTag(); + secDto.title = secEl.getAttribute("title"); + secDto.articles = new ArrayList<>(); + + allSections.add(secDto); + sectionMap.put(secEl, secDto); + } + } + } + + private static Document parseXmlString(String xml) { + //TODO: 예외처리 후 삭제 + try { + return documentBuilderFactory.newDocumentBuilder() + .parse(new InputSource(new StringReader(xml))); + } catch (SAXException e) { + // System.out.println(xml); + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (ParserConfigurationException e) { + //TODO DocumentBulider 생성 실패 + throw new RuntimeException(e); + } + } + + private static boolean isXmlNull(String xml) { + if (xml == null || xml.trim().isEmpty() || xml == "null") { + return true; + } else { + return false; + } + } + + private static class DocTag implements Tags { + public String title; + public String type; + public List sections; + + DocTag(Element root, List sections) { + this.title = root.getAttribute("title"); + this.type = root.getAttribute("type"); + this.sections = sections; + } + + @Override + public void addTag(Tags tags) { + sections.add((SectionTag) tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof DocTag; + } + } + + private static class SectionTag implements Tags { + public String title; + public List articles; + + @Override + public void addTag(Tags tags) { + articles.add((ArticleTag)tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof SectionTag; + } + } + + private static class ArticleTag implements Tags { + public String title; + public List paragraphs; + + @Override + public void addTag(Tags tags) { + paragraphs.add((ParagraphTag) tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof ArticleTag; + } + } + + public static class ParagraphTag implements Tags { + public String tagName; + public String textIndent; + public String marginLeft; + public String text; + + @Override + public void addTag(Tags tags) { + //TODO: addTag Exception 하위 없음 + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof ParagraphTag; + } + } + + public static interface Tags { + void addTag(Tags tags); + boolean equalsClass(Tags tags); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c0bd10d..8f3ff5b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,11 +11,22 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: none + ddl-auto: create # ddl-auto: create - show-sql: true +# show-sql: true properties: hibernate: format_sql: true +#logging: +# level: +# root: DEBUG +gov: + host: apis.data.go.kr + serviceKey: ${GOV_SERVICE_KEY} + numOfRows: 100 + type: json + path: + detail: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05 + img: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnInq06 server: port: 8084 From 8f903546438ced2ab810cb676f80197963869cbb Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 10:48:38 +0900 Subject: [PATCH 07/25] =?UTF-8?q?=F0=9F=93=A6=20Chore:=20Git-Action=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=83=81=ED=83=9C=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index e36508b..e7b999b 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -35,3 +35,10 @@ jobs: cd /deploy docker-compose down docker-compose up -d --build + + - name: Wait for test server to be healthy + run: | + echo "🔍 Checking https://yakplus-test.techlog.dev/actuator/health ..." + curl --silent --fail \ + --retry 5 --retry-connrefused --retry-delay 5 \ + https://yakplus-test.techlog.dev/actuator/health \ No newline at end of file From 118c1ae325aab3f34385d0d40e063d19221f324a Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 10:57:41 +0900 Subject: [PATCH 08/25] =?UTF-8?q?=E2=9C=A8=20Feat:=20JDBC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../in/DrugApprovalDetailScraperUseCase.java | 2 + .../port/out/DrugDetailRepositoryPort.java | 2 + .../service/DrugApprovalDetailScraper.java | 27 ++++++++++ .../yakplus/domain/model/GovDrugDetail.java | 2 + .../out/GovDrugDetailRepositoryAdapter.java | 11 +++++ .../repository/GovDrugJdbcRepository.java | 42 ++++++++++++++++ .../repository/JdbcBatchSetter.java | 49 +++++++++++++++++++ .../controller/DrugDetailController.java | 7 +++ .../support/api/ApiResponseMapper.java | 21 ++++---- 10 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java diff --git a/build.gradle b/build.gradle index ff144b2..5aa932e 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + // Elastic search implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java index 53a5bb2..b1e42b0 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java @@ -4,4 +4,6 @@ public interface DrugApprovalDetailScraperUseCase { void requestUpdateRawData(); void requestUpdateAllRawData(); + + void requestUpdateAllRawDataByJdbc(); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java index bfe6c84..841c7fb 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java @@ -8,4 +8,6 @@ public interface DrugDetailRepositoryPort { List findAll(String code); void saveAllAndFlush(List entities); + + void saveAllByJdbc(List entities); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java index 31fd972..f670a98 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java @@ -77,6 +77,33 @@ public void requestUpdateAllRawData() { } + @Override + public void requestUpdateAllRawDataByJdbc() { + int pageNo = 1; + int receivedCount = 0; + int savedCountWithoutDuplicates = 0; + + String response = fetchPage(pageNo); + int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); + + while (hasMoreData(receivedCount, totalCount)) { + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List drugs = toListFromJson(items); + receivedCount += drugs.size(); + + // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) + int uniqueItems = deduplicateByItemSeq(drugs); + savedCountWithoutDuplicates += uniqueItems; + + drugDetailRepositoryPort.saveAllByJdbc(drugs); + + log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", + pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); + + response = fetchPage(++pageNo); + } + } + private List toListFromJson(JsonNode items) { log.info("items 약품 객체로 맵핑"); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java index 8e99de9..f40b4ac 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java @@ -7,8 +7,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Builder; +import lombok.Getter; @Builder +@Getter public class GovDrugDetail { private Long drugId; private String drugName; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java index cc8928c..39c3d17 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java @@ -5,17 +5,21 @@ import org.springframework.stereotype.Component; import com.likelion.backendplus4.yakplus.application.port.out.DrugDetailRepositoryPort; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrugJdbcRepository; import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrungDetailJpaRepository; import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor public class GovDrugDetailRepositoryAdapter implements DrugDetailRepositoryPort { private final GovDrungDetailJpaRepository govDrungDetailJpaRepository; + private final GovDrugJdbcRepository govDrugJdbcRepository; @Override public List findAll(String code) { @@ -25,9 +29,16 @@ public List findAll(String code) { @Override @Transactional public void saveAllAndFlush(List entities) { + log.info("JPA로 DB저장"); govDrungDetailJpaRepository.saveAllAndFlush(entities); } + @Override + public void saveAllByJdbc(List entities) { + log.info("JDBC로 DB저장"); + govDrugJdbcRepository.saveAll(entities); + } + public GovDrugDetail toDomainFromEntity(GovDrugDetailEntity detail){ return GovDrugDetail.builder() .drugId(detail.getDrugId()) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java new file mode 100644 index 0000000..15bb263 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java @@ -0,0 +1,42 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; + +import java.util.List; + +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class GovDrugJdbcRepository { + private final JdbcTemplate jdbc; + + @Transactional + public void saveAll(List entities) { + String sql = "" + + "INSERT INTO gov_drug_detail " + + " (ITEM_SEQ, ITEM_NAME, ENTP_NAME, " + + " ITEM_PERMIT_DATE, ETC_OTC_CODE, MATERIAL_NAME, " + + " STORAGE_METHOD, VALID_TERM, EE_DOC_DATA, " + + " UD_DOC_DATA, NB_DOC_DATA) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE " + + " ITEM_NAME = VALUES(ITEM_NAME), " + + " ENTP_NAME = VALUES(ENTP_NAME), " + + " ITEM_PERMIT_DATE = VALUES(ITEM_PERMIT_DATE), " + + " ETC_OTC_CODE = VALUES(ETC_OTC_CODE), " + + " MATERIAL_NAME = VALUES(MATERIAL_NAME), " + + " STORAGE_METHOD = VALUES(STORAGE_METHOD), " + + " VALID_TERM = VALUES(VALID_TERM), " + + " EE_DOC_DATA = VALUES(EE_DOC_DATA), " + + " UD_DOC_DATA = VALUES(UD_DOC_DATA), " + + " NB_DOC_DATA = VALUES(NB_DOC_DATA)"; + jdbc.batchUpdate(sql, new JdbcBatchSetter(entities)); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java new file mode 100644 index 0000000..0af8cd8 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java @@ -0,0 +1,49 @@ +package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.jdbc.core.BatchPreparedStatementSetter; + +import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; +import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JdbcBatchSetter implements BatchPreparedStatementSetter { + + private final List entities; + + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + GovDrugDetailEntity e = entities.get(i); + ps.setLong (1, e.getDrugId()); + ps.setString (2, e.getDrugName()); + ps.setString (3, e.getCompany()); + + LocalDate permit = e.getPermitDate(); + if (permit != null) { + ps.setDate(4, Date.valueOf(permit)); + } else { + ps.setNull(4, Types.DATE); + } + + ps.setBoolean(5, e.isGeneral()); + ps.setString (6, e.getMaterialInfo()); + ps.setString (7, e.getStoreMethod()); + ps.setString (8, e.getValidTerm()); + ps.setString (9, e.getEfficacy()); + ps.setString (10, e.getUsage()); + ps.setString (11, e.getPrecaution()); + } + + @Override + public int getBatchSize() { + return entities.size(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java index ee67a0c..c7401e1 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import com.likelion.backendplus4.yakplus.application.port.in.DrugApprovalDetailScraperUseCase; @@ -24,4 +25,10 @@ public ResponseEntity saveAPIDataAll(){ scraperUseCase.requestUpdateAllRawData(); return ResponseEntity.ok().build(); } + + @PostMapping("/gov/api/parser/detail/startAll") + public ResponseEntity saveAPIDataAllByJdbc(){ + scraperUseCase.requestUpdateAllRawDataByJdbc(); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java index d198795..529a167 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java @@ -26,16 +26,17 @@ public static JsonNode getItemsFromResponse(String response) { public static int getTotalCountFromResponse(String response) { log.info("응답에서 데이터 사이즈 추출"); - try { - return new ObjectMapper().readTree(response) - .path("body") - .path("totalCount") - .asInt(); - } catch (JsonProcessingException e) { - log.error("totalCount 추출 실패"); - //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 - throw new RuntimeException(e); - } + return 10_000; + // try { + // return new ObjectMapper().readTree(response) + // .path("body") + // .path("totalCount") + // .asInt(); + // } catch (JsonProcessingException e) { + // log.error("totalCount 추출 실패"); + // //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 + // throw new RuntimeException(e); + // } } } From 9b7341132092d4e3966f0018e8686927b4116496 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 11:36:57 +0900 Subject: [PATCH 09/25] =?UTF-8?q?=F0=9F=9A=A8=20Hotfix:=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=84=A4=EC=A0=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backendplus4/yakplus/common/configuration/WebConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java index c45cae7..e2d1ff8 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/WebConfig.java @@ -19,7 +19,7 @@ public class WebConfig implements WebMvcConfigurer { private static final String ALL_PATTERN = "/**"; - private LogInterceptor logInterceptor; + private final LogInterceptor logInterceptor; /** * 인터셉터를 등록하는 메서드 From 8b5bbee8e7acd6575cd016aadc84aac7d9e3933c Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 14:38:26 +0900 Subject: [PATCH 10/25] =?UTF-8?q?=20=E2=9C=A8Feature/#4=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=B5=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦Chore: API 응답 파싱을 위한 의존성 라이브러리, 환경변수 추가 * ✨Feat: API 요청용 객체 추가 * 📦 Chore: 주석 추가 * ✨ Feat: 의약품 상세정보 API 호출 개발 * ✨ Feat: 샘플 검색 데이터 추출 개발 * ✨ Feat: 공공데이터 API 파싱 개발 * ♻️ Refactor: 파싱을 위한 Wrappeer 클래스 정리 (인터페이스 추가) * ♻️ Refactor: 불필요한 생성자 삭제 * ♻️ Refactor: aricle parsing 가독성 개선 * 🐛 Fix: SectionWrapper parseElement 오타 수정 * ♻️ Refactor: 로직 수정 및 가독성 개선 * ♻️ Refactor: Paragraph 파싱 수정 및 헥사고날 아키텍처 적용 * ♻️ Refactor: 도메인 메소드 접근자 변경 * 📦 gitignore 수정 및 재 커밋 * ♻️ Refactor: 공공데이터 개요정보 API 파싱 수정 * ✨ Feat: API 전체 데이터 저장 * ✨ Feat: api 전체 데이터 저장 * 🐛 Fix: Xml 파서에 null 체크 로직 추가 * ✨ Feat: 공공데이터 전체 저장 기능 개발 * ♻️ Refactor: 전체 데이터 저장 함수 리팩토링 --------- Co-authored-by: HaechangLee <112938092+HaechangLee@users.noreply.github.com> --------- Co-authored-by: HaechangLee <112938092+HaechangLee@users.noreply.github.com> --- build.gradle | 1 - .../scraper/drug/ApiResponseMapper.java | 41 ++++ .../scraper/drug/ApiRestTemplateConfig.java | 19 ++ .../drug/detail/ApiDataDrugJPARepo.java | 10 + .../detail/adapter/in/batch/BatchConfig.java | 5 + .../detail/adapter/in/batch/JobLauncher.java | 5 + .../adapter/in/web/DrugDetailController.java | 27 +++ .../adapter/out/gov/ApiUriCompBuilder.java | 100 ++++++++ .../adapter/out/parser/MaterialParser.java | 59 +++++ .../detail/adapter/out/parser/XMLParser.java | 217 ++++++++++++++++++ .../out/persistence/GovDrugDetailEntity.java | 89 +++++++ .../GovDrugDetailRepositoryAdapter.java | 42 ++++ .../GovDrungDetailJpaRepository.java | 6 + .../in/DrugApprovalDetailScraperUseCase.java | 7 + .../port/out/DrugDetailParserPort.java | 4 + .../port/out/DrugDetailRepositoryPort.java | 11 + .../service/DrugApprovalDetailScraper.java | 156 +++++++++++++ .../service/RawDataParseService.java | 20 ++ .../detail/domain/model/GovDrugDetail.java | 33 +++ .../img/controller/DragImageController.java | 19 ++ .../img/repository/ApiDataDrugImgEntity.java | 20 ++ .../img/repository/ApiDataDrugImgRepo.java | 8 + .../img/service/DrungImageGovScraper.java | 51 ++++ 23 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java diff --git a/build.gradle b/build.gradle index 5aa932e..172f63c 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,6 @@ dependencies { implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml' implementation 'org.springframework.boot:spring-boot-starter-jdbc' - // Elastic search implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.17.10' diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java new file mode 100644 index 0000000..9b4b703 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java @@ -0,0 +1,41 @@ +package com.likelion.backendplus4.yakplus.scraper.drug; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ApiResponseMapper { + + public static JsonNode getItemsFromResponse(String response) { + log.info("응답에서 items 값 추출"); + try { + return new ObjectMapper().readTree(response) + .path("body") + .path("items"); + } catch (JsonProcessingException e) { + log.error("items 추출 실패"); + //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 + throw new RuntimeException(e); + } + } + + public static int getTotalCountFromResponse(String response) { + log.info("응답에서 데이터 사이즈 추출"); + try { + return new ObjectMapper().readTree(response) + .path("body") + .path("totalCount") + .asInt(); + } catch (JsonProcessingException e) { + log.error("totalCount 추출 실패"); + //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java new file mode 100644 index 0000000..ade0c9b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.scraper.drug; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * Api 요청을 보내기 위한 RestTemplate 빈 생성 + * + * @since 2025-04-15 + * @author 함예정 + */ +@Configuration +public class ApiRestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java new file mode 100644 index 0000000..1b1feb3 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java @@ -0,0 +1,10 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; + +@Repository +public interface ApiDataDrugJPARepo extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java new file mode 100644 index 0000000..2b508dd --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.batch; + +public class BatchConfig { + //TODO: 추후 배치로 분리 +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java new file mode 100644 index 0000000..15f574e --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.batch; + +public class JobLauncher { + //TODO: 추후 배치로 분리 +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java new file mode 100644 index 0000000..76981ac --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java @@ -0,0 +1,27 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.web; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class DrugDetailController { + private final DrugApprovalDetailScraperUseCase scraperUseCase; + + @GetMapping("/gov/api/parser/detail/start") + public ResponseEntity saveAPIData(){ + scraperUseCase.requestUpdateRawData(); + return ResponseEntity.ok().build(); + } + + @GetMapping("/gov/api/parser/detail/startAll") + public ResponseEntity saveAPIDataAll(){ + scraperUseCase.requestUpdateAllRawData(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java new file mode 100644 index 0000000..ba1721e --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java @@ -0,0 +1,100 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +/*** + * API 요청 URI 객체 생성 빌더 + * + * application.yml에서 주입되는 속성 값으로, + * API HOST, PATH를 확인해 URI 객체를 만듭니다. + * + * @since 2025-04-15 + * @author 함예정 + */ +@Component +public class ApiUriCompBuilder { + private final String SERVICE_KEY; + private final String HOST; + private final String API_DETAIL_PATH; + private final String API_IMG_PATH ; + private final String RESPONSE_TYPE; + + public ApiUriCompBuilder(@Value("${gov.host}") String host, + @Value("${gov.serviceKey}") String serviceKey, + @Value("${gov.path.detail}") String pathDetail, + @Value("${gov.path.img}") String pathImg, + @Value("${gov.type}") String type) { + this.HOST = host; + this.SERVICE_KEY = serviceKey; + this.API_DETAIL_PATH = pathDetail; + this.API_IMG_PATH = pathImg; + this.RESPONSE_TYPE = type; + } + + /*** + * 입력 받은 path를 반영해 URI 객체를 생성, 반환 + * + * @param path API 요청 경로 + * @return URI + * + * @since 2025-04-15 + * @author 함예정 + */ + private URI getUri(String path, int pageNo) { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(HOST) + .port(443) + .path(path) + .queryParam("serviceKey", SERVICE_KEY) + .queryParam("type", RESPONSE_TYPE) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", 100) + .build(true) + .toUri(); + } + + /*** + * 식품의약품안전처 의약품 제품 허가 상세 정보 URI 반환 + * @return URI 제품 허가 상세 정보 + * + * @since 2025-04-15 + * @author 함예정 + */ + public URI getUriForDetailApi(int pageNo) { + return getUri(API_DETAIL_PATH, pageNo); + } + + /*** + * 식품의약품안전처 의약품 제품 허가 목록 URI 반환 + * @return URI 제품 허가 목록 + * + * @since 2025-04-15 + * @author 함예정 + */ + public URI getUriForImgApi(int pageNo) { + return getUri(API_IMG_PATH, pageNo); + } + + // TODO 추후 삭제 + public URI getUriForImgApiBySeq(String seq) { + // 임시 URI + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(HOST) + .port(443) + .path(API_IMG_PATH) + .queryParam("serviceKey", SERVICE_KEY) + .queryParam("type", RESPONSE_TYPE) + .queryParam("numOfRows", 1) + .queryParam("prdlst_Stdr_code", seq) + .build(true) + .toUri(); + + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java new file mode 100644 index 0000000..b3ac4ec --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java @@ -0,0 +1,59 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class MaterialParser { + public static String parseMaterial(String raw) throws Exception { + ObjectMapper result = new ObjectMapper(); + ArrayNode resultArray = result.createArrayNode(); + String[] blocks = splitBlock(raw); + parsingblocksAndPutArrayItem(blocks, resultArray); + return convertString(result, resultArray); + } + + private static void parsingblocksAndPutArrayItem(String[] blocks, ArrayNode resultArray) { + for (String block : blocks) { + block = block.trim(); + if (block.isEmpty()) { + continue; + } + String[] pairs = splitByPipe(block); + ObjectNode item = makeItem(pairs); + resultArray.add(item); + } + } + + private static String convertString(ObjectMapper objectMapper, ArrayNode arrayNode) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(arrayNode); + } catch (JsonProcessingException e) { + //TODO String 변환실패 + throw new RuntimeException(e); + } + } + + private static ObjectNode makeItem(String[] pairs) { + ObjectNode item = new ObjectMapper().createObjectNode(); + for (String pair : pairs) { + String[] kv = pair.split(":", 2); + String key = kv[0].trim(); + String value = ""; + if(kv.length == 2){ + value = kv[1].trim(); + } + item.put(key, value); + } + return item; + } + + private static String[] splitByPipe(String block) { + return block.split("\\|"); + } + + private static String[] splitBlock(String raw) { + return raw.split(";"); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java new file mode 100644 index 0000000..8cd0229 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java @@ -0,0 +1,217 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class XMLParser { + public static String toJson(String xml) { + + if(isXmlNull(xml)) { + return "{\"\": \"\"}"; + } + + Document doc = parseXmlString(xml); + Element root = doc.getDocumentElement(); + + List allSections = new ArrayList<>(); + List allArticles = new ArrayList<>(); + List allParagraphs = new ArrayList<>(); + + Map sectionMap = new HashMap<>(); + Map articleMap = new HashMap<>(); + + DocTag docTag = new DocTag(root, allSections); + parseSesctions(root, allSections, sectionMap); + parseArticles(root, allArticles, articleMap, sectionMap); + parseParagraph(root, allParagraphs, articleMap); + return convertJson(docTag); + } + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + + private static String convertJson(DocTag docTag) { + try { + return mapper.writeValueAsString(docTag); + //TODO: 예외처리 후 삭제 + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static void parseParagraph(Element root, List allParagraphs, Map articleMap) { + NodeList paraNodes = root.getElementsByTagName("PARAGRAPH"); + + if(paraNodes.getLength() != 0){ + for (int i = 0; i < paraNodes.getLength(); i++) { + Element paragraphElement = (Element) paraNodes.item(i); + ParagraphTag paragraphTag = new ParagraphTag(); + paragraphTag.tagName = paragraphElement.getAttribute("tagName"); + paragraphTag.textIndent = paragraphElement.getAttribute("textIndent"); + paragraphTag.marginLeft = paragraphElement.getAttribute("marginLeft"); + paragraphTag.text = paragraphElement.getTextContent().trim(); + + allParagraphs.add(paragraphTag); + mapSectionFromArticle(articleMap, paragraphTag, paragraphElement); + } + } + + } + + private static void parseArticles(Element root, List allArticles, + Map articleMap, + Map sectionMap) { + NodeList artNodes = root.getElementsByTagName("ARTICLE"); + if(artNodes.getLength() > 0) { + for (int i = 0; i < artNodes.getLength(); i++) { + Element artElement = (Element) artNodes.item(i); + ArticleTag articleTag = new ArticleTag(); + articleTag.title = artElement.getAttribute("title"); + articleTag.paragraphs = new ArrayList<>(); + + allArticles.add(articleTag); + articleMap.put(artElement, articleTag); + mapSectionFromArticle(sectionMap, articleTag, artElement); + } + } + + } + + private static void mapSectionFromArticle(Map map, Tags tags, Element element) { + Element parentElement = (Element) element.getParentNode(); + Tags parentTag = map.get(parentElement); + if (parentTag != null) { + parentTag.addTag(tags); + } + } + + private static void parseSesctions(Element root, List allSections, Map sectionMap) { + NodeList secNodes = root.getElementsByTagName("SECTION"); + + if(secNodes.getLength() > 0) { + for (int i = 0; i < secNodes.getLength(); i++) { + Element secEl = (Element) secNodes.item(i); + SectionTag secDto = new SectionTag(); + secDto.title = secEl.getAttribute("title"); + secDto.articles = new ArrayList<>(); + + allSections.add(secDto); + sectionMap.put(secEl, secDto); + } + } + } + + private static Document parseXmlString(String xml) { + //TODO: 예외처리 후 삭제 + try { + return documentBuilderFactory.newDocumentBuilder() + .parse(new InputSource(new StringReader(xml))); + } catch (SAXException e) { + // System.out.println(xml); + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (ParserConfigurationException e) { + //TODO DocumentBulider 생성 실패 + throw new RuntimeException(e); + } + } + + private static boolean isXmlNull(String xml) { + if (xml == null || xml.trim().isEmpty() || xml == "null") { + return true; + } else { + return false; + } + } + + private static class DocTag implements Tags { + public String title; + public String type; + public List sections; + + DocTag(Element root, List sections) { + this.title = root.getAttribute("title"); + this.type = root.getAttribute("type"); + this.sections = sections; + } + + @Override + public void addTag(Tags tags) { + sections.add((SectionTag) tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof DocTag; + } + } + + private static class SectionTag implements Tags { + public String title; + public List articles; + + @Override + public void addTag(Tags tags) { + articles.add((ArticleTag)tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof SectionTag; + } + } + + private static class ArticleTag implements Tags { + public String title; + public List paragraphs; + + @Override + public void addTag(Tags tags) { + paragraphs.add((ParagraphTag) tags); + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof ArticleTag; + } + } + + public static class ParagraphTag implements Tags { + public String tagName; + public String textIndent; + public String marginLeft; + public String text; + + @Override + public void addTag(Tags tags) { + //TODO: addTag Exception 하위 없음 + } + + @Override + public boolean equalsClass(Tags tags) { + return tags instanceof ParagraphTag; + } + } + + public static interface Tags { + void addTag(Tags tags); + boolean equalsClass(Tags tags); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java new file mode 100644 index 0000000..0fc62d9 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java @@ -0,0 +1,89 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.time.LocalDate; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Table(name = "gov_drug_detail") +public class GovDrugDetailEntity { + + @Id + @JsonProperty("ITEM_SEQ") + @Column( name= "ITEM_SEQ") + private Long drugId; + + @JsonProperty("ITEM_NAME") + @Column( name= "ITEM_NAME", columnDefinition = "TEXT") + private String drugName; + + @JsonProperty("ENTP_NAME") + @Column( name= "ENTP_NAME") + private String company; + + @JsonProperty("ITEM_PERMIT_DATE") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd") + @Column( name= "ITEM_PERMIT_DATE") + private LocalDate permitDate; + + @Column(name = "ETC_OTC_CODE") + private boolean isGeneral; + + @Column(name = "MATERIAL_NAME", columnDefinition = "JSON") + private String materialInfo; + + @JsonProperty("STORAGE_METHOD") + @Column(name = "STORAGE_METHOD", columnDefinition = "TEXT") + private String storeMethod; + + @JsonProperty("VALID_TERM") + @Column(name = "VALID_TERM") + private String validTerm; + + @Column(name = "EE_DOC_DATA", columnDefinition = "JSON") + private String efficacy; + + @Column(name = "UD_DOC_DATA", columnDefinition = "JSON") + private String usage; + + @Column(name = "NB_DOC_DATA", columnDefinition = "JSON") + private String precaution; + + @JsonCreator + public GovDrugDetailEntity(@JsonProperty("ETC_OTC_CODE") String drugType) { + this.isGeneral = !"전문의약품".equals(drugType); + } + + public void changeMaterialInfo(String materialInfo){ + this.materialInfo = materialInfo; + } + + public void changeUsage(String usage) { + this.usage = usage; + } + + public void changeEfficacy(String efficacy) { + this.efficacy = efficacy; + } + + public void changePrecaution(String precaution) { + this.precaution = precaution; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java new file mode 100644 index 0000000..c059978 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java @@ -0,0 +1,42 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrugDetail; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GovDrugDetailRepositoryAdapter implements DrugDetailRepositoryPort { + private final GovDrungDetailJpaRepository govDrungDetailJpaRepository; + + @Override + public List findAll(String code) { + return govDrungDetailJpaRepository.findAll(); + } + + @Override + public void saveAllAndFlush(List entities) { + govDrungDetailJpaRepository.saveAllAndFlush(entities); + } + + public GovDrugDetail toDomainFromEntity(GovDrugDetailEntity detail){ + return GovDrugDetail.builder() + .drugId(detail.getDrugId()) + .drugName(detail.getDrugName()) + .company(detail.getCompany()) + .permitDate(detail.getPermitDate()) + .isGeneral(detail.isGeneral()) + .materialInfo(detail.getMaterialInfo()) + .storeMethod(detail.getStoreMethod()) + .validTerm(detail.getValidTerm()) + .efficacy(detail.getEfficacy()) + .usage(detail.getUsage()) + .precaution(detail.getPrecaution()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java new file mode 100644 index 0000000..45be610 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java @@ -0,0 +1,6 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GovDrungDetailJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java new file mode 100644 index 0000000..c32e37c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java @@ -0,0 +1,7 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in; + +public interface DrugApprovalDetailScraperUseCase { + void requestUpdateRawData(); + + void requestUpdateAllRawData(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java new file mode 100644 index 0000000..522e50d --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java @@ -0,0 +1,4 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; + +public interface DrugDetailParserPort { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java new file mode 100644 index 0000000..8068322 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java @@ -0,0 +1,11 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; + +import java.util.List; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; + +public interface DrugDetailRepositoryPort { + + List findAll(String code); + void saveAllAndFlush(List entities); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java new file mode 100644 index 0000000..31f6ea5 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java @@ -0,0 +1,156 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser.MaterialParser; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser.XMLParser; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DrugApprovalDetailScraper implements DrugApprovalDetailScraperUseCase { + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + private final ApiUriCompBuilder apiUriCompBuilder; + private final DrugDetailRepositoryPort drugDetailRepositoryPort; + + @Transactional + @Override + public void requestUpdateRawData() { + log.info("API 데이터 요청"); + String response = restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(2), String.class); + log.debug("API Response: {}", response); + + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List drugs = toListFromJson(items); + for (GovDrugDetailEntity drug : drugs) { + System.out.println(drug); + } + drugDetailRepositoryPort.saveAllAndFlush(drugs); + } + + @Transactional + @Override + public void requestUpdateAllRawData() { + int pageNo = 1; + int receivedCount = 0; + int savedCountWithoutDuplicates = 0; + + String response = fetchPage(pageNo); + int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); + + while (hasMoreData(receivedCount, totalCount)) { + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List drugs = toListFromJson(items); + receivedCount += drugs.size(); + + // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) + int uniqueItems = deduplicateByItemSeq(drugs); + savedCountWithoutDuplicates += uniqueItems; + + drugDetailRepositoryPort.saveAllAndFlush(drugs); + + log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", + pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); + + response = fetchPage(++pageNo); + } + + } + + private List toListFromJson(JsonNode items) { + + log.info("items 약품 객체로 맵핑"); + try { + List apiDataDrugDetails = toApiDetails(items); + for (int i = 0; i < apiDataDrugDetails.size(); i++) { + GovDrugDetailEntity drugDetail = apiDataDrugDetails.get(i); + JsonNode item = items.get(i); + log.debug("item seq: " + item.get("ITEM_SEQ").asText()); + + String materialRawData = item.get("MATERIAL_NAME").asText(); + String materialInfo = MaterialParser.parseMaterial(materialRawData); + drugDetail.changeMaterialInfo(materialInfo); + + String efficacyXmlText = item.get("EE_DOC_DATA").asText(); + String efficacy = XMLParser.toJson(efficacyXmlText); + drugDetail.changeEfficacy(efficacy); + + String usageXmlText = items.get(i).get("UD_DOC_DATA").asText(); + String usages = XMLParser.toJson(usageXmlText); + drugDetail.changeUsage(usages); + + String precautionxmlText = items.get(i).get("NB_DOC_DATA").asText(); + String precautions = XMLParser.toJson(precautionxmlText); + drugDetail.changePrecaution(precautions); + } + return apiDataDrugDetails; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private List toApiDetails(JsonNode items) { + try { + return objectMapper.readValue(items.toString(), + new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + // private JsonNode toJsonFromXml(String usageXmlText) throws JsonProcessingException { + // XmlMapper xmlMapper = new XmlMapper(); + // + // JsonNode jsonNode = xmlMapper.readTree(usageXmlText) + // .path("SECTION") + // .path("ARTICLE"); + // return jsonNode; + // } + + // TODO: 추후 삭제 예정 + // private String replaceText(String text){ + // return text.replace("ᆞ ", "&") + // .replace("• ","") + // .replace("〜 ", "~"); + // } + + private int deduplicateByItemSeq(List drugs) { + // itemseq 기준으로 set에 저장 --> set은 중복 허용하지 않으므로 item seq 다 넣으면 알아서 중복 없이 저장됨 + Set uniqueItems = new HashSet<>(); + + for (GovDrugDetailEntity drug : drugs) { + uniqueItems.add(drug.getDrugId()); + } + return uniqueItems.size(); + } + + private String fetchPage(int pageNo) { + return restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(pageNo), String.class); + } + + private boolean hasMoreData(int receivedCount, int totalCount) { + return receivedCount < totalCount; + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java new file mode 100644 index 0000000..eed53cc --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java @@ -0,0 +1,20 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RawDataParseService { + private final DrugApprovalDetailScraperUseCase drugApprovalDetailScraperUseCase; + + public void requestUpdateRawData() { + drugApprovalDetailScraperUseCase.requestUpdateRawData(); + } + + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java new file mode 100644 index 0000000..0d0999f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java @@ -0,0 +1,33 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; + +import java.time.LocalDate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Builder; + +@Builder +public class GovDrugDetail { + private Long drugId; + private String drugName; + private String company; + private LocalDate permitDate; + private boolean isGeneral; + private String materialInfo; + private String storeMethod; + private String validTerm; + private String efficacy; + private String usage; + private String precaution; + + public JsonNode toJson(String json) { + try { + return new ObjectMapper().readValue(json, JsonNode.class); + } catch (JsonProcessingException e) { + //TODO 에러 로그 처리 필요합니다. + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java new file mode 100644 index 0000000..6613988 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.backendplus4.yakplus.scraper.drug.img.service.DrungImageGovScraper; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DragImageController { + private final DrungImageGovScraper imageScraper; + + @GetMapping("/gov/api/parser/image/start") + public void test(){ + imageScraper.getApiData(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java new file mode 100644 index 0000000..403aeb3 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java @@ -0,0 +1,20 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img.repository; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.ToString; + +@Entity +@ToString +@Table(name="API_DATA_DRUG_IMG") +public class ApiDataDrugImgEntity { + @Id + @JsonProperty("ITEM_SEQ") + private Long seq; + + @JsonProperty("BIG_PRDT_IMG_URL") + private String imgUrl; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java new file mode 100644 index 0000000..e73c9aa --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApiDataDrugImgRepo extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java new file mode 100644 index 0000000..7d297f3 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java @@ -0,0 +1,51 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img.service; + +import java.net.URI; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.ApiDataDrugJPARepo; +import com.likelion.backendplus4.yakplus.scraper.drug.img.repository.ApiDataDrugImgRepo; +import com.likelion.backendplus4.yakplus.scraper.drug.img.repository.ApiDataDrugImgEntity; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DrungImageGovScraper { + private final ApiUriCompBuilder uriCompBuilder; + private final RestTemplate restTemplate; + private final ApiDataDrugImgRepo imgRepo; + private final ObjectMapper objectMapper; + //TODO: 추후 삭제 + private final ApiDataDrugJPARepo detailRepo; + + @Transactional + public void getApiData(){ + log.info("의약품 개요 정보 API 호출 시작"); + + URI uriForImgApi = uriCompBuilder.getUriForImgApi(); + + String response = restTemplate.getForObject(uriForImgApi, String.class); + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + List imgDatas = null; + try { + imgDatas = objectMapper.readValue(items.toString(), + new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + imgRepo.saveAllAndFlush(imgDatas); + } +} From 822822c66091e6cbd5cf0fc9517f516abdc4f07f Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 14:47:54 +0900 Subject: [PATCH 11/25] =?UTF-8?q?=E2=9C=A8=20Feat:=20#19=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=B5=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B0=92=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦Chore: API 응답 파싱을 위한 의존성 라이브러리, 환경변수 추가 * ✨Feat: API 요청용 객체 추가 * 📦 Chore: 주석 추가 * ✨ Feat: 의약품 상세정보 API 호출 개발 * ✨ Feat: 샘플 검색 데이터 추출 개발 * ✨ Feat: 공공데이터 API 파싱 개발 * ♻️ Refactor: 파싱을 위한 Wrappeer 클래스 정리 (인터페이스 추가) * ♻️ Refactor: 불필요한 생성자 삭제 * ♻️ Refactor: aricle parsing 가독성 개선 * 🐛 Fix: SectionWrapper parseElement 오타 수정 * ♻️ Refactor: 로직 수정 및 가독성 개선 * ♻️ Refactor: Paragraph 파싱 수정 및 헥사고날 아키텍처 적용 * ♻️ Refactor: 도메인 메소드 접근자 변경 * 📦 gitignore 수정 및 재 커밋 * ✨ Feat: api 전체 데이터 저장 * 🐛 Fix: Xml 파서에 null 체크 로직 추가 * ✨ Feat: 공공데이터 전체 저장 기능 개발 * ♻️ Refactor: 전체 데이터 저장 함수 리팩토링 * ✨ Feat: 공공데이터 도메인 객체 추가 --------- Co-authored-by: HaechangLee <112938092+HaechangLee@users.noreply.github.com> --- .../backendplus4/yakplus/RestTempTest.java | 36 +++++++ .../scraper/drug/ApiResponseMapper.java | 1 - .../adapter/in/web/ApiDataTestController.java | 27 +++++ .../adapter/out/gov/ApiUriCompBuilder.java | 8 +- .../out/persistence/GovDrugEntity.java | 62 +++++++++++ .../out/persistence/GovDrugJpaRepository.java | 6 ++ .../persistence/GovDrugRepositoryAdapter.java | 21 ++++ .../application/port/in/DrugDataUseCase.java | 9 ++ .../port/out/DrugRepositoryPort.java | 9 ++ .../application/service/DrugDataMapper.java | 23 ++++ .../application/service/DrugDataService.java | 32 ++++++ .../drug/detail/domain/model/GovDrug.java | 100 ++++++++++++++++++ .../drug/detail/domain/model/Material.java | 31 ++++++ .../detail/domain/model/MaterialInfo.java | 8 ++ .../drug/detail/domain/model/WarningType.java | 45 ++++++++ .../scraper/drug/img/ApiDataDrugImg.java | 18 ++++ .../scraper/drug/img/ApiDataDrugImgRepo.java | 8 ++ .../scraper/drug/img/ImageScraper.java | 69 ++++++++++++ .../yakplus/scraper/drug/img/Test2Ctrl.java | 19 ++++ 19 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java b/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java new file mode 100644 index 0000000..24a2eaf --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java @@ -0,0 +1,36 @@ +package com.likelion.backendplus4.yakplus; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.client.RestTemplate; + +@Controller +public class RestTempTest { + + @GetMapping("/sibar") + public void tester(){ + RestTempTest.main(); + } + public static void main() { + try { + // URI uri = UriComponentsBuilder.newInstance() + // .scheme("https") + // .host("apis.data.go.kr") + // .port(443) + // .path("/1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05") + // .queryParam("serviceKey", + // "kHctorEOW58GMVU768tVFqRHC6ytY0HrNiZxXY10hMb7UkEkZJKzmW+1uSx5bgM/wi9h94UoZ31oKDh+xKjQGQ==") + // .queryParam("type", "json") + // .queryParam("numOfRows", 1) + // .build(true) + // .toUri(); + + RestTemplate template = new RestTemplate(); + String url = "https://apis.data.go.kr/1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05?type=json&numOfRows=5&serviceKey=kHctorEOW58GMVU768tVFqRHC6ytY0HrNiZxXY10hMb7UkEkZJKzmW+1uSx5bgM/wi9h94UoZ31oKDh+xKjQGQ=="; + String response = template.getForObject(url, String.class); + System.out.println("response = " + response); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java index 9b4b703..79f39c7 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java @@ -37,5 +37,4 @@ public static int getTotalCountFromResponse(String response) { throw new RuntimeException(e); } } - } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java new file mode 100644 index 0000000..2cc6254 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java @@ -0,0 +1,27 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.web; + +import java.util.List; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugDataUseCase; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@Slf4j +@RequiredArgsConstructor +public class ApiDataTestController { + private final DrugDataUseCase drugDataUseCase; + + @GetMapping("/data/all") + public List getAllData(){ + log.info("getAllData"); + return drugDataUseCase.findAllRawDrug(); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java index ba1721e..b6bd65c 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java @@ -22,17 +22,22 @@ public class ApiUriCompBuilder { private final String API_DETAIL_PATH; private final String API_IMG_PATH ; private final String RESPONSE_TYPE; + private final int NUM_OF_ROWS; + public ApiUriCompBuilder(@Value("${gov.host}") String host, @Value("${gov.serviceKey}") String serviceKey, @Value("${gov.path.detail}") String pathDetail, @Value("${gov.path.img}") String pathImg, + @Value("${gov.type}") String type, + @Value("${gov.numOfRows}") int numOfRows @Value("${gov.type}") String type) { this.HOST = host; this.SERVICE_KEY = serviceKey; this.API_DETAIL_PATH = pathDetail; this.API_IMG_PATH = pathImg; this.RESPONSE_TYPE = type; + this.NUM_OF_ROWS = numOfRows; } /*** @@ -53,7 +58,7 @@ private URI getUri(String path, int pageNo) { .queryParam("serviceKey", SERVICE_KEY) .queryParam("type", RESPONSE_TYPE) .queryParam("pageNo", pageNo) - .queryParam("numOfRows", 100) + .queryParam("numOfRows", NUM_OF_ROWS) .build(true) .toUri(); } @@ -96,5 +101,4 @@ public URI getUriForImgApiBySeq(String seq) { .toUri(); } - } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java new file mode 100644 index 0000000..75cf8a2 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java @@ -0,0 +1,62 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name="GOV_DRUG_RAW_DATA") +public class GovDrugEntity { + @Id + @Column(name="ITEM_SEQ") + private Long id; + + @Column( name= "ITEM_NAME", columnDefinition = "TEXT") + private String drugName; + + @Column( name= "ENTP_NAME") + private String company; + + @Column( name= "ITEM_PERMIT_DATE") + private LocalDate permitDate; + + @Column(name = "ETC_OTC_CODE") + private boolean isGeneral; + + @Column(name = "MATERIAL_NAME", columnDefinition = "JSON") + private String materialInfo; + + @JsonProperty("STORAGE_METHOD") + @Column(name = "STORAGE_METHOD", columnDefinition = "TEXT") + private String storeMethod; + + @Column(name = "VALID_TERM") + private String validTerm; + + @Column(name = "EE_DOC_DATA", columnDefinition = "JSON") + private String efficacy; + + @Column(name = "UD_DOC_DATA", columnDefinition = "JSON") + private String usage; + + @Column(name = "NB_DOC_DATA", columnDefinition = "JSON") + private String precaution; + + @Column(name= "IMG_URL") + private String imageUrl; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java new file mode 100644 index 0000000..3396760 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java @@ -0,0 +1,6 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GovDrugJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java new file mode 100644 index 0000000..43cb0f0 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java @@ -0,0 +1,21 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugRepositoryPort; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GovDrugRepositoryAdapter implements DrugRepositoryPort { + private final GovDrugJpaRepository govDrungJpaRepository; + + @Override + public List findAll() { + return govDrungJpaRepository.findAll(); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java new file mode 100644 index 0000000..51be1a2 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in; + +import java.util.List; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; + +public interface DrugDataUseCase { + List findAllRawDrug(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java new file mode 100644 index 0000000..cce5acf --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; + +import java.util.List; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; + +public interface DrugRepositoryPort { + List findAll(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java new file mode 100644 index 0000000..464f929 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java @@ -0,0 +1,23 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; + +public class DrugDataMapper { + public static GovDrug toDomainFromEntity(GovDrugEntity e){ + return GovDrug.builder() + .drugId(e.getId()) + .drugName(e.getDrugName()) + .company(e.getCompany()) + .permitDate(e.getPermitDate()) + .isGeneral(e.isGeneral()) + .materialInfo(e.getMaterialInfo()) + .storeMethod(e.getStoreMethod()) + .validTerm(e.getValidTerm()) + .efficacy(e.getEfficacy()) + .usage(e.getUsage()) + .precaution(e.getPrecaution()) + .imageUrl(e.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java new file mode 100644 index 0000000..dda18f5 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java @@ -0,0 +1,32 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; + +import static java.util.stream.Collectors.*; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugDataUseCase; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugRepositoryPort; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Service +public class DrugDataService implements DrugDataUseCase { + private final DrugRepositoryPort repositoryPort; + + @Override + public List findAllRawDrug() { + log.info("findAllRawDrug called"); + return repositoryPort.findAll().stream() + .map(DrugDataMapper::toDomainFromEntity) + .collect(toList()); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java new file mode 100644 index 0000000..be56b2c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java @@ -0,0 +1,100 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class GovDrug { + private Long drugId; + private String drugName; + private String company; + private LocalDate permitDate; + private boolean isGeneral; + private String materialInfo; + private String storeMethod; + private String validTerm; + private String efficacy; + private String usage; + private String precaution; + private String imageUrl; + + public List getMaterialInfo() { + List matrerials = new ArrayList<>(); + + try { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode json = objectMapper.readTree(materialInfo); + + if (json.isArray()) { + for (JsonNode node : json) { + Material ingredient = objectMapper.treeToValue(node, Material.class); + matrerials.add(ingredient); + } + } + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + + return matrerials; + } + + public List getEfficacy() { + List efficacys = new ArrayList<>(); + try { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode json = objectMapper.readTree(this.efficacy); + for (JsonNode section : json.get("sections")) { + for (JsonNode article : section.get("articles")) { + for (JsonNode paragraph : article.get("paragraphs")) { + efficacys.add(paragraph.get("text").asText()); + } + } + } + } catch (JsonProcessingException e) { + //TODO: 예외처리 + throw new RuntimeException(e); + } + return efficacys; + } + + public Map> getPrecaution() { + ObjectMapper objectMapper = new ObjectMapper(); + Map> result = new LinkedHashMap<>(); + + try { + JsonNode json = objectMapper.readTree(this.precaution); + JsonNode articles = json.get("sections").get(0).get("articles"); + + for (JsonNode article : articles) { + String rawTitle = article.get("title").asText(); + WarningType type = WarningType.fromLabel(rawTitle); + + List texts = new ArrayList<>(); + for (JsonNode paragraph : article.get("paragraphs")) { + texts.add(paragraph.get("text").asText()); + } + + result.put(type, texts); + } + + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + + return result; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java new file mode 100644 index 0000000..0beb271 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java @@ -0,0 +1,31 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class Material { + @JsonProperty("성분명") + private String name; + + @JsonProperty("분량") + private String amount; + + @JsonProperty("단위") + private String unit; + + @JsonProperty("총량") + private String totalAmount; + + @JsonProperty("규격") + private String standard; + + @JsonProperty("비고") + private String note; + + @JsonProperty("성분정보") + private String info; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java new file mode 100644 index 0000000..98df156 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; + +import java.util.List; + +public class MaterialInfo { + private String totalAmount; + private List ingredients; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java new file mode 100644 index 0000000..cb18a3b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java @@ -0,0 +1,45 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum WarningType { + WARNING("경고"), + DO_NOT_ADMINISTER("다음 환자에는 투여하지 말 것."), + CAUTION_ADMINISTER("다음 환자에는 신중히 투여할 것."), + ADVERSE_REACTIONS("이상반응"), + GENERAL_CAUTION("일반적 주의"), + PREGNANCY("임부에 대한 투여"), + PREGNANCY2("임부, 수유부, 가임여성, 신생아, 유아, 소아, 고령자에 대한 투여"), + PEDIATRIC("소아에 대한 투여"), + ELDERLY("고령자에 대한 투여"), + OVERDOSE("과량투여시의 처치"), + USAGE_NOTES("적용상의 주의"), + STORE_NOTES("보관 및 취급상의 주의사항"); + + private final String label; + + WarningType(String label) { + this.label = label; + } + + @JsonValue + public String getLabel() { + return label; + } + + @JsonCreator + public static WarningType fromLabel(String title) { + String cleaned = removeLeadingNumber(title); + for (WarningType type : values()) { + if (type.label.equals(cleaned)) { + return type; + } + } + throw new IllegalArgumentException("Unknown title: " + title); + } + + private static String removeLeadingNumber(String title) { + return title.replaceFirst("^\\d+\\.\\s*", ""); // 숫자 + 점 + 공백 제거 + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java new file mode 100644 index 0000000..bf714c9 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.ToString; + +@Entity +@ToString +public class ApiDataDrugImg { + @Id + @JsonProperty("ITEM_SEQ") + private Long seq; + + @JsonProperty("BIG_PRDT_IMG_URL") + private String imgUrl; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java new file mode 100644 index 0000000..941b95a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApiDataDrugImgRepo extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java new file mode 100644 index 0000000..674bd42 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java @@ -0,0 +1,69 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.scraper.drug.detail.ApiDataDrugJPARepo; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ImageScraper { + private final ApiUriCompBuilder uriCompBuilder; + private final RestTemplate restTemplate; + private final ApiDataDrugImgRepo imgRepo; + private final ObjectMapper objectMapper; + //TODO: 추후 삭제 + private final ApiDataDrugJPARepo detailRepo; + + @Transactional + public void getApiData(){ + log.info("의약품 개요 정보 API 호출 시작"); + + // URI uriForImgApi = uriCompBuilder.getUriForImgApi(); + // ApiDataDrugImg object = restTemplate.getForObject(uriForImgApi, + // new TypeReference>() {}); + + + //100번 반복 + List oldDatas = detailRepo.findAll(); + List imgDatas = new ArrayList<>(); + + for (GovDrugDetailEntity oldData : oldDatas) { + System.out.println("oldData = " + oldData.getDrugId()); + URI uri = uriCompBuilder.getUriForImgApiBySeq(oldData.getDrugId().toString()); + String response = restTemplate.getForObject(uri, String.class); + JsonNode items = ApiResponseMapper.getItemsFromResponse(response); + + try { + if(items.isArray()){ + for (JsonNode item : items) { + ApiDataDrugImg data = objectMapper.readValue(item.toString(), + ApiDataDrugImg.class); + System.out.println("data = " + data); + imgDatas.add(data); + } + } + } catch (JsonProcessingException e) { + log.error("객체 맵핑 실패"); + throw new RuntimeException(e); + } + } + System.out.println("imgDatas.size() = " + imgDatas.size()); + imgRepo.saveAllAndFlush(imgDatas); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java new file mode 100644 index 0000000..2536c3b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.scraper.drug.img; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/test2") +public class Test2Ctrl { + private final ImageScraper imageScraper; + + @GetMapping + public void test(){ + imageScraper.getApiData(); + } +} From e98a11729fe221709c0a11d5c5f1bbf791592188 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 22 Apr 2025 17:37:06 +0900 Subject: [PATCH 12/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backendplus4/yakplus/RestTempTest.java | 36 --- .../in/DrugApprovalDetailScraperUseCase.java | 9 - .../port/out/DrugDetailRepositoryPort.java | 13 -- .../service/DrugApprovalDetailScraper.java | 9 + .../DrugApprovalDetailScraperImpl.java} | 36 ++- .../application/service/DrugDataService.java | 9 + .../service/DrugDataServiceImpl.java | 29 +++ .../service/DrugImageGovScraper.java | 10 +- .../detail => drug}/domain/model/GovDrug.java | 4 +- .../domain/model/GovDrugDetail.java | 2 +- .../domain/model/vo}/Material.java | 2 +- .../domain/model/vo}/MaterialInfo.java | 2 +- .../domain/model/vo}/WarningType.java | 2 +- .../exception/ScraperException.java | 2 +- .../exception/error/ScraperErrorCode.java | 2 +- .../entity/ApiDataDrugImgEntity.java | 2 +- .../entity}/GovDrugDetailEntity.java | 2 +- .../repository/entity}/GovDrugEntity.java | 4 +- .../jdbc}/GovDrugJdbcRepository.java | 6 +- .../repository/jdbc}/JdbcBatchSetter.java | 5 +- .../repository/jpa/ApiDataDrugImgRepo.java | 10 + .../jpa/GovDrugDetailJpaRepository.java | 18 ++ .../repository/jpa/GovDrugJpaRepository.java | 8 + .../config/ApiRestTemplateConfig.java | 2 +- .../support/api/ApiResponseMapper.java | 2 +- .../support/api}/ApiUriCompBuilder.java | 28 +-- .../support/mapper}/DrugDataMapper.java | 6 +- .../support/parser/MaterialParser.java | 2 +- .../support/parser/XMLParser.java | 2 +- .../controller/DrugDataTestController.java | 25 ++ .../controller/DrugDetailController.java | 6 +- .../controller/DrugImageController.java} | 6 +- .../out/GovDrugDetailRepositoryAdapter.java | 57 ----- .../repository/ApiDataDrugImgRepo.java | 10 - .../GovDrungDetailJpaRepository.java | 8 - .../entity/GovDrugDetailEntity.java | 89 ------- .../presentation/batch/BatchConfig.java | 5 - .../presentation/batch/JobLauncher.java | 5 - .../scraper/drug/ApiResponseMapper.java | 40 ---- .../scraper/drug/ApiRestTemplateConfig.java | 19 -- .../drug/detail/ApiDataDrugJPARepo.java | 10 - .../detail/adapter/in/batch/BatchConfig.java | 5 - .../detail/adapter/in/batch/JobLauncher.java | 5 - .../adapter/in/web/ApiDataTestController.java | 27 --- .../adapter/in/web/DrugDetailController.java | 27 --- .../adapter/out/parser/MaterialParser.java | 59 ----- .../detail/adapter/out/parser/XMLParser.java | 217 ------------------ .../GovDrugDetailRepositoryAdapter.java | 42 ---- .../out/persistence/GovDrugJpaRepository.java | 6 - .../persistence/GovDrugRepositoryAdapter.java | 21 -- .../GovDrungDetailJpaRepository.java | 6 - .../in/DrugApprovalDetailScraperUseCase.java | 7 - .../application/port/in/DrugDataUseCase.java | 9 - .../port/out/DrugDetailParserPort.java | 4 - .../port/out/DrugDetailRepositoryPort.java | 11 - .../port/out/DrugRepositoryPort.java | 9 - .../service/DrugApprovalDetailScraper.java | 156 ------------- .../application/service/DrugDataService.java | 32 --- .../service/RawDataParseService.java | 20 -- .../detail/domain/model/GovDrugDetail.java | 33 --- .../scraper/drug/img/ApiDataDrugImg.java | 18 -- .../scraper/drug/img/ApiDataDrugImgRepo.java | 8 - .../scraper/drug/img/ImageScraper.java | 69 ------ .../yakplus/scraper/drug/img/Test2Ctrl.java | 19 -- .../img/controller/DragImageController.java | 19 -- .../img/repository/ApiDataDrugImgEntity.java | 20 -- .../img/repository/ApiDataDrugImgRepo.java | 8 - .../img/service/DrungImageGovScraper.java | 51 ---- .../support/api/ApiUriCompBuilder.java | 100 -------- src/main/resources/application.yml | 1 - 70 files changed, 161 insertions(+), 1392 deletions(-) delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java rename src/main/java/com/likelion/backendplus4/yakplus/{application/service/DrugApprovalDetailScraper.java => drug/application/service/DrugApprovalDetailScraperImpl.java} (82%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug}/application/service/DrugImageGovScraper.java (72%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail => drug}/domain/model/GovDrug.java (92%) rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug}/domain/model/GovDrugDetail.java (93%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/domain/model => drug/domain/model/vo}/Material.java (86%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/domain/model => drug/domain/model/vo}/MaterialInfo.java (61%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/domain/model => drug/domain/model/vo}/WarningType.java (94%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper => drug}/exception/ScraperException.java (88%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper => drug}/exception/error/ScraperErrorCode.java (94%) rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug}/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java (79%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/adapter/out/persistence => drug/infrastructure/adapter/persistence/repository/entity}/GovDrugDetailEntity.java (95%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/adapter/out/persistence => drug/infrastructure/adapter/persistence/repository/entity}/GovDrugEntity.java (87%) rename src/main/java/com/likelion/backendplus4/yakplus/{infrastructure/adapter/persistence/repository => drug/infrastructure/adapter/persistence/repository/jdbc}/GovDrugJdbcRepository.java (79%) rename src/main/java/com/likelion/backendplus4/yakplus/{infrastructure/adapter/persistence/repository => drug/infrastructure/adapter/persistence/repository/jdbc}/JdbcBatchSetter.java (80%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug}/infrastructure/config/ApiRestTemplateConfig.java (85%) rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug/infrastructure}/support/api/ApiResponseMapper.java (93%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/adapter/out/gov => drug/infrastructure/support/api}/ApiUriCompBuilder.java (73%) rename src/main/java/com/likelion/backendplus4/yakplus/{scraper/drug/detail/application/service => drug/infrastructure/support/mapper}/DrugDataMapper.java (65%) rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug/infrastructure}/support/parser/MaterialParser.java (95%) rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug/infrastructure}/support/parser/XMLParser.java (98%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java rename src/main/java/com/likelion/backendplus4/yakplus/{ => drug}/presentation/controller/DrugDetailController.java (79%) rename src/main/java/com/likelion/backendplus4/yakplus/{presentation/controller/DragImageController.java => drug/presentation/controller/DrugImageController.java} (65%) delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java b/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java deleted file mode 100644 index 24a2eaf..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/RestTempTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.likelion.backendplus4.yakplus; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.client.RestTemplate; - -@Controller -public class RestTempTest { - - @GetMapping("/sibar") - public void tester(){ - RestTempTest.main(); - } - public static void main() { - try { - // URI uri = UriComponentsBuilder.newInstance() - // .scheme("https") - // .host("apis.data.go.kr") - // .port(443) - // .path("/1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05") - // .queryParam("serviceKey", - // "kHctorEOW58GMVU768tVFqRHC6ytY0HrNiZxXY10hMb7UkEkZJKzmW+1uSx5bgM/wi9h94UoZ31oKDh+xKjQGQ==") - // .queryParam("type", "json") - // .queryParam("numOfRows", 1) - // .build(true) - // .toUri(); - - RestTemplate template = new RestTemplate(); - String url = "https://apis.data.go.kr/1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05?type=json&numOfRows=5&serviceKey=kHctorEOW58GMVU768tVFqRHC6ytY0HrNiZxXY10hMb7UkEkZJKzmW+1uSx5bgM/wi9h94UoZ31oKDh+xKjQGQ=="; - String response = template.getForObject(url, String.class); - System.out.println("response = " + response); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java deleted file mode 100644 index b1e42b0..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/port/in/DrugApprovalDetailScraperUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.application.port.in; - -public interface DrugApprovalDetailScraperUseCase { - void requestUpdateRawData(); - - void requestUpdateAllRawData(); - - void requestUpdateAllRawDataByJdbc(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java deleted file mode 100644 index 841c7fb..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/port/out/DrugDetailRepositoryPort.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.likelion.backendplus4.yakplus.application.port.out; - -import java.util.List; - -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - -public interface DrugDetailRepositoryPort { - - List findAll(String code); - void saveAllAndFlush(List entities); - - void saveAllByJdbc(List entities); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java new file mode 100644 index 0000000..9ad4592 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.drug.application.service; + +public interface DrugApprovalDetailScraper { + void requestUpdateRawData(); + + void requestUpdateAllRawData(); + + void requestUpdateAllRawDataByJdbc(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java similarity index 82% rename from src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java index f670a98..63eb994 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugApprovalDetailScraper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java @@ -1,19 +1,18 @@ -package com.likelion.backendplus4.yakplus.application.service; +package com.likelion.backendplus4.yakplus.drug.application.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -// import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import com.likelion.backendplus4.yakplus.support.api.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.support.api.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.support.parser.MaterialParser; -import com.likelion.backendplus4.yakplus.support.parser.XMLParser; -import com.likelion.backendplus4.yakplus.application.port.in.DrugApprovalDetailScraperUseCase; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; -import com.likelion.backendplus4.yakplus.application.port.out.DrugDetailRepositoryPort; - -import jakarta.transaction.Transactional; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc.GovDrugJdbcRepository; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugDetailJpaRepository; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.MaterialParser; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.XMLParser; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,13 +26,13 @@ @Slf4j @Component @RequiredArgsConstructor -public class DrugApprovalDetailScraper implements DrugApprovalDetailScraperUseCase { +public class DrugApprovalDetailScraperImpl implements DrugApprovalDetailScraper { private final ObjectMapper objectMapper; private final RestTemplate restTemplate; private final ApiUriCompBuilder apiUriCompBuilder; - private final DrugDetailRepositoryPort drugDetailRepositoryPort; + private final GovDrugDetailJpaRepository govDrugDetailJpaRepository; + private final GovDrugJdbcRepository govDrugJdbcRepository; - @Transactional @Override public void requestUpdateRawData() { log.info("API 데이터 요청"); @@ -42,10 +41,7 @@ public void requestUpdateRawData() { JsonNode items = ApiResponseMapper.getItemsFromResponse(response); List drugs = toListFromJson(items); - for (GovDrugDetailEntity drug : drugs) { - System.out.println(drug); - } - drugDetailRepositoryPort.saveAllAndFlush(drugs); + govDrugDetailJpaRepository.saveAllAndFlush(drugs); } @@ -67,7 +63,7 @@ public void requestUpdateAllRawData() { int uniqueItems = deduplicateByItemSeq(drugs); savedCountWithoutDuplicates += uniqueItems; - drugDetailRepositoryPort.saveAllAndFlush(drugs); + govDrugDetailJpaRepository.saveAllAndFlush(drugs); log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); @@ -95,7 +91,7 @@ public void requestUpdateAllRawDataByJdbc() { int uniqueItems = deduplicateByItemSeq(drugs); savedCountWithoutDuplicates += uniqueItems; - drugDetailRepositoryPort.saveAllByJdbc(drugs); + govDrugJdbcRepository.saveAll(drugs); log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java new file mode 100644 index 0000000..eabec2b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.drug.application.service; + +import java.util.List; + +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; + +public interface DrugDataService { + List findAllRawDrug(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java new file mode 100644 index 0000000..9ce3101 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java @@ -0,0 +1,29 @@ +package com.likelion.backendplus4.yakplus.drug.application.service; + +import static java.util.stream.Collectors.*; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugJpaRepository; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Service +public class DrugDataServiceImpl implements DrugDataService { + private final GovDrugJpaRepository govDrugJpaRepository; + + @Override + public List findAllRawDrug() { + log.info("findAllRawDrug called"); + return govDrugJpaRepository.findAll().stream() + .map(DrugDataMapper::toDomainFromEntity) + .collect(toList()); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java similarity index 72% rename from src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java index 4b998a7..78a4359 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/application/service/DrugImageGovScraper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.application.service; +package com.likelion.backendplus4.yakplus.drug.application.service; import java.net.URI; import java.util.List; @@ -10,10 +10,10 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.support.api.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.support.api.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.ApiDataDrugImgRepo; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiResponseMapper; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiUriCompBuilder; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.ApiDataDrugImgRepo; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java similarity index 92% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java index be56b2c..aa20b2d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrug.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; +package com.likelion.backendplus4.yakplus.drug.domain.model; import java.time.LocalDate; import java.util.ArrayList; @@ -9,6 +9,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; +import com.likelion.backendplus4.yakplus.drug.domain.model.vo.WarningType; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java similarity index 93% rename from src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java index f40b4ac..bb7ceb0 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/domain/model/GovDrugDetail.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.domain.model; +package com.likelion.backendplus4.yakplus.drug.domain.model; import java.time.LocalDate; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java similarity index 86% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java index 0beb271..fab8c35 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/Material.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; +package com.likelion.backendplus4.yakplus.drug.domain.model.vo; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java similarity index 61% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java index 98df156..75f29df 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/MaterialInfo.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; +package com.likelion.backendplus4.yakplus.drug.domain.model.vo; import java.util.List; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java similarity index 94% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java index cb18a3b..38640bf 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/WarningType.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; +package com.likelion.backendplus4.yakplus.drug.domain.model.vo; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java similarity index 88% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java index 67174db..7f47c4d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/ScraperException.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.exception; +package com.likelion.backendplus4.yakplus.drug.exception; import com.likelion.backendplus4.yakplus.common.exception.CustomException; import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java similarity index 94% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java index 124c1b6..017d57d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/exception/error/ScraperErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.exception.error; +package com.likelion.backendplus4.yakplus.drug.exception.error; import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java similarity index 79% rename from src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java index 5a13e32..3b4cbdf 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity; +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java similarity index 95% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java index 0fc62d9..6c06157 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailEntity.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonFormat; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java similarity index 87% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java index 75cf8a2..c879ccf 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugEntity.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java @@ -1,9 +1,7 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; import java.time.LocalDate; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.Column; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java similarity index 79% rename from src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java index 15bb263..6b008bc 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrugJdbcRepository.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java @@ -1,13 +1,11 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc; import java.util.List; -import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; -import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java similarity index 80% rename from src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java index 0af8cd8..7310690 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/JdbcBatchSetter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc; import java.sql.Date; import java.sql.PreparedStatement; @@ -9,8 +9,7 @@ import org.springframework.jdbc.core.BatchPreparedStatementSetter; -import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java new file mode 100644 index 0000000..12a8800 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java @@ -0,0 +1,10 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; + +@Repository +public interface ApiDataDrugImgRepo extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java new file mode 100644 index 0000000..4ca4706 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; + +import jakarta.transaction.Transactional; + +@Repository +public interface GovDrugDetailJpaRepository extends JpaRepository { + + @Override + @Transactional + List saveAllAndFlush(Iterable entities); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java new file mode 100644 index 0000000..b6ab711 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugEntity; + +public interface GovDrugJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java similarity index 85% rename from src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java index d7cd39d..a449759 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/config/ApiRestTemplateConfig.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.infrastructure.config; +package com.likelion.backendplus4.yakplus.drug.infrastructure.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java similarity index 93% rename from src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java index 529a167..c48cf28 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiResponseMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.support.api; +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.api; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java similarity index 73% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java index b6bd65c..f3312f6 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/gov/ApiUriCompBuilder.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov; +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.api; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -21,22 +21,17 @@ public class ApiUriCompBuilder { private final String HOST; private final String API_DETAIL_PATH; private final String API_IMG_PATH ; - private final String RESPONSE_TYPE; private final int NUM_OF_ROWS; - public ApiUriCompBuilder(@Value("${gov.host}") String host, @Value("${gov.serviceKey}") String serviceKey, @Value("${gov.path.detail}") String pathDetail, @Value("${gov.path.img}") String pathImg, - @Value("${gov.type}") String type, - @Value("${gov.numOfRows}") int numOfRows - @Value("${gov.type}") String type) { + @Value("${gov.numOfRows}") int numOfRows) { this.HOST = host; this.SERVICE_KEY = serviceKey; this.API_DETAIL_PATH = pathDetail; this.API_IMG_PATH = pathImg; - this.RESPONSE_TYPE = type; this.NUM_OF_ROWS = numOfRows; } @@ -56,7 +51,7 @@ private URI getUri(String path, int pageNo) { .port(443) .path(path) .queryParam("serviceKey", SERVICE_KEY) - .queryParam("type", RESPONSE_TYPE) + .queryParam("type", "json") .queryParam("pageNo", pageNo) .queryParam("numOfRows", NUM_OF_ROWS) .build(true) @@ -84,21 +79,4 @@ public URI getUriForDetailApi(int pageNo) { public URI getUriForImgApi(int pageNo) { return getUri(API_IMG_PATH, pageNo); } - - // TODO 추후 삭제 - public URI getUriForImgApiBySeq(String seq) { - // 임시 URI - return UriComponentsBuilder.newInstance() - .scheme("https") - .host(HOST) - .port(443) - .path(API_IMG_PATH) - .queryParam("serviceKey", SERVICE_KEY) - .queryParam("type", RESPONSE_TYPE) - .queryParam("numOfRows", 1) - .queryParam("prdlst_Stdr_code", seq) - .build(true) - .toUri(); - - } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java similarity index 65% rename from src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java index 464f929..c5e8b83 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java @@ -1,7 +1,7 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugEntity; +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; public class DrugDataMapper { public static GovDrug toDomainFromEntity(GovDrugEntity e){ diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java similarity index 95% rename from src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java index ff0f62a..ee4f867 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/MaterialParser.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.support.parser; +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java similarity index 98% rename from src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java index e5f7614..8c7680b 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/support/parser/XMLParser.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.support.parser; +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; import java.io.IOException; import java.io.StringReader; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java new file mode 100644 index 0000000..c36be75 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java @@ -0,0 +1,25 @@ +package com.likelion.backendplus4.yakplus.drug.presentation.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.backendplus4.yakplus.drug.application.service.DrugDataService; +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@Slf4j +@RequiredArgsConstructor +public class DrugDataTestController { + private final DrugDataService dragDataService; + + @GetMapping("/data/all") + public List getAllData(){ + log.info("getAllData"); + return dragDataService.findAllRawDrug(); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java similarity index 79% rename from src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java index c7401e1..7c87340 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DrugDetailController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java @@ -1,18 +1,18 @@ -package com.likelion.backendplus4.yakplus.presentation.controller; +package com.likelion.backendplus4.yakplus.drug.presentation.controller; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import com.likelion.backendplus4.yakplus.application.port.in.DrugApprovalDetailScraperUseCase; +import com.likelion.backendplus4.yakplus.drug.application.service.DrugApprovalDetailScraper; import lombok.RequiredArgsConstructor; @Controller @RequiredArgsConstructor public class DrugDetailController { - private final DrugApprovalDetailScraperUseCase scraperUseCase; + private final DrugApprovalDetailScraper scraperUseCase; @GetMapping("/gov/api/parser/detail/start") public ResponseEntity saveAPIData(){ diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java similarity index 65% rename from src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java rename to src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java index 0fa6dca..cf68610 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/presentation/controller/DragImageController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java @@ -1,15 +1,15 @@ -package com.likelion.backendplus4.yakplus.presentation.controller; +package com.likelion.backendplus4.yakplus.drug.presentation.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import com.likelion.backendplus4.yakplus.application.service.DrugImageGovScraper; +import com.likelion.backendplus4.yakplus.drug.application.service.DrugImageGovScraper; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -public class DragImageController { +public class DrugImageController { private final DrugImageGovScraper imageScraper; @GetMapping("/gov/api/parser/image/start") diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java deleted file mode 100644 index 39c3d17..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/out/GovDrugDetailRepositoryAdapter.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.out; - -import java.util.List; - -import org.springframework.stereotype.Component; - -import com.likelion.backendplus4.yakplus.application.port.out.DrugDetailRepositoryPort; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrugJdbcRepository; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrungDetailJpaRepository; -import com.likelion.backendplus4.yakplus.domain.model.GovDrugDetail; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class GovDrugDetailRepositoryAdapter implements DrugDetailRepositoryPort { - private final GovDrungDetailJpaRepository govDrungDetailJpaRepository; - private final GovDrugJdbcRepository govDrugJdbcRepository; - - @Override - public List findAll(String code) { - return govDrungDetailJpaRepository.findAll(); - } - - @Override - @Transactional - public void saveAllAndFlush(List entities) { - log.info("JPA로 DB저장"); - govDrungDetailJpaRepository.saveAllAndFlush(entities); - } - - @Override - public void saveAllByJdbc(List entities) { - log.info("JDBC로 DB저장"); - govDrugJdbcRepository.saveAll(entities); - } - - public GovDrugDetail toDomainFromEntity(GovDrugDetailEntity detail){ - return GovDrugDetail.builder() - .drugId(detail.getDrugId()) - .drugName(detail.getDrugName()) - .company(detail.getCompany()) - .permitDate(detail.getPermitDate()) - .isGeneral(detail.isGeneral()) - .materialInfo(detail.getMaterialInfo()) - .storeMethod(detail.getStoreMethod()) - .validTerm(detail.getValidTerm()) - .efficacy(detail.getEfficacy()) - .usage(detail.getUsage()) - .precaution(detail.getPrecaution()) - .build(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java deleted file mode 100644 index c2c1593..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/ApiDataDrugImgRepo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; - -@Repository -public interface ApiDataDrugImgRepo extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java deleted file mode 100644 index 6871dc0..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/GovDrungDetailJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - -public interface GovDrungDetailJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java deleted file mode 100644 index 925be23..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.time.LocalDate; - -@Entity -@Builder -@Getter -@NoArgsConstructor -@AllArgsConstructor -@ToString -@Table(name = "gov_drug_detail") -public class GovDrugDetailEntity { - - @Id - @JsonProperty("ITEM_SEQ") - @Column( name= "ITEM_SEQ") - private Long drugId; - - @JsonProperty("ITEM_NAME") - @Column( name= "ITEM_NAME", columnDefinition = "TEXT") - private String drugName; - - @JsonProperty("ENTP_NAME") - @Column( name= "ENTP_NAME") - private String company; - - @JsonProperty("ITEM_PERMIT_DATE") - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd") - @Column( name= "ITEM_PERMIT_DATE") - private LocalDate permitDate; - - @Column(name = "ETC_OTC_CODE") - private boolean isGeneral; - - @Column(name = "MATERIAL_NAME", columnDefinition = "JSON") - private String materialInfo; - - @JsonProperty("STORAGE_METHOD") - @Column(name = "STORAGE_METHOD", columnDefinition = "TEXT") - private String storeMethod; - - @JsonProperty("VALID_TERM") - @Column(name = "VALID_TERM") - private String validTerm; - - @Column(name = "EE_DOC_DATA", columnDefinition = "JSON") - private String efficacy; - - @Column(name = "UD_DOC_DATA", columnDefinition = "JSON") - private String usage; - - @Column(name = "NB_DOC_DATA", columnDefinition = "JSON") - private String precaution; - - @JsonCreator - public GovDrugDetailEntity(@JsonProperty("ETC_OTC_CODE") String drugType) { - this.isGeneral = !"전문의약품".equals(drugType); - } - - public void changeMaterialInfo(String materialInfo){ - this.materialInfo = materialInfo; - } - - public void changeUsage(String usage) { - this.usage = usage; - } - - public void changeEfficacy(String efficacy) { - this.efficacy = efficacy; - } - - public void changePrecaution(String precaution) { - this.precaution = precaution; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java deleted file mode 100644 index df403e8..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/BatchConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.likelion.backendplus4.yakplus.presentation.batch; - -public class BatchConfig { - //TODO: 추후 배치로 분리 -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java b/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java deleted file mode 100644 index 0490bdc..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/presentation/batch/JobLauncher.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.likelion.backendplus4.yakplus.presentation.batch; - -public class JobLauncher { - //TODO: 추후 배치로 분리 -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java deleted file mode 100644 index 79f39c7..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiResponseMapper.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug; - -import org.springframework.stereotype.Component; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class ApiResponseMapper { - - public static JsonNode getItemsFromResponse(String response) { - log.info("응답에서 items 값 추출"); - try { - return new ObjectMapper().readTree(response) - .path("body") - .path("items"); - } catch (JsonProcessingException e) { - log.error("items 추출 실패"); - //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 - throw new RuntimeException(e); - } - } - - public static int getTotalCountFromResponse(String response) { - log.info("응답에서 데이터 사이즈 추출"); - try { - return new ObjectMapper().readTree(response) - .path("body") - .path("totalCount") - .asInt(); - } catch (JsonProcessingException e) { - log.error("totalCount 추출 실패"); - //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 - throw new RuntimeException(e); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java deleted file mode 100644 index ade0c9b..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/ApiRestTemplateConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -/** - * Api 요청을 보내기 위한 RestTemplate 빈 생성 - * - * @since 2025-04-15 - * @author 함예정 - */ -@Configuration -public class ApiRestTemplateConfig { - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java deleted file mode 100644 index 1b1feb3..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/ApiDataDrugJPARepo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; - -@Repository -public interface ApiDataDrugJPARepo extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java deleted file mode 100644 index 2b508dd..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/BatchConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.batch; - -public class BatchConfig { - //TODO: 추후 배치로 분리 -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java deleted file mode 100644 index 15f574e..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/batch/JobLauncher.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.batch; - -public class JobLauncher { - //TODO: 추후 배치로 분리 -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java deleted file mode 100644 index 2cc6254..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/ApiDataTestController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.web; - -import java.util.List; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugDataUseCase; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RestController -@Slf4j -@RequiredArgsConstructor -public class ApiDataTestController { - private final DrugDataUseCase drugDataUseCase; - - @GetMapping("/data/all") - public List getAllData(){ - log.info("getAllData"); - return drugDataUseCase.findAllRawDrug(); - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java deleted file mode 100644 index 76981ac..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/in/web/DrugDetailController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.in.web; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; - -import lombok.RequiredArgsConstructor; - -@Controller -@RequiredArgsConstructor -public class DrugDetailController { - private final DrugApprovalDetailScraperUseCase scraperUseCase; - - @GetMapping("/gov/api/parser/detail/start") - public ResponseEntity saveAPIData(){ - scraperUseCase.requestUpdateRawData(); - return ResponseEntity.ok().build(); - } - - @GetMapping("/gov/api/parser/detail/startAll") - public ResponseEntity saveAPIDataAll(){ - scraperUseCase.requestUpdateAllRawData(); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java deleted file mode 100644 index b3ac4ec..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/MaterialParser.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -public class MaterialParser { - public static String parseMaterial(String raw) throws Exception { - ObjectMapper result = new ObjectMapper(); - ArrayNode resultArray = result.createArrayNode(); - String[] blocks = splitBlock(raw); - parsingblocksAndPutArrayItem(blocks, resultArray); - return convertString(result, resultArray); - } - - private static void parsingblocksAndPutArrayItem(String[] blocks, ArrayNode resultArray) { - for (String block : blocks) { - block = block.trim(); - if (block.isEmpty()) { - continue; - } - String[] pairs = splitByPipe(block); - ObjectNode item = makeItem(pairs); - resultArray.add(item); - } - } - - private static String convertString(ObjectMapper objectMapper, ArrayNode arrayNode) { - try { - return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(arrayNode); - } catch (JsonProcessingException e) { - //TODO String 변환실패 - throw new RuntimeException(e); - } - } - - private static ObjectNode makeItem(String[] pairs) { - ObjectNode item = new ObjectMapper().createObjectNode(); - for (String pair : pairs) { - String[] kv = pair.split(":", 2); - String key = kv[0].trim(); - String value = ""; - if(kv.length == 2){ - value = kv[1].trim(); - } - item.put(key, value); - } - return item; - } - - private static String[] splitByPipe(String block) { - return block.split("\\|"); - } - - private static String[] splitBlock(String raw) { - return raw.split(";"); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java deleted file mode 100644 index 8cd0229..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/parser/XMLParser.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser; - -import java.io.IOException; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -public class XMLParser { - public static String toJson(String xml) { - - if(isXmlNull(xml)) { - return "{\"\": \"\"}"; - } - - Document doc = parseXmlString(xml); - Element root = doc.getDocumentElement(); - - List allSections = new ArrayList<>(); - List allArticles = new ArrayList<>(); - List allParagraphs = new ArrayList<>(); - - Map sectionMap = new HashMap<>(); - Map articleMap = new HashMap<>(); - - DocTag docTag = new DocTag(root, allSections); - parseSesctions(root, allSections, sectionMap); - parseArticles(root, allArticles, articleMap, sectionMap); - parseParagraph(root, allParagraphs, articleMap); - return convertJson(docTag); - } - private static final ObjectMapper mapper = new ObjectMapper(); - - private static final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); - - private static String convertJson(DocTag docTag) { - try { - return mapper.writeValueAsString(docTag); - //TODO: 예외처리 후 삭제 - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - private static void parseParagraph(Element root, List allParagraphs, Map articleMap) { - NodeList paraNodes = root.getElementsByTagName("PARAGRAPH"); - - if(paraNodes.getLength() != 0){ - for (int i = 0; i < paraNodes.getLength(); i++) { - Element paragraphElement = (Element) paraNodes.item(i); - ParagraphTag paragraphTag = new ParagraphTag(); - paragraphTag.tagName = paragraphElement.getAttribute("tagName"); - paragraphTag.textIndent = paragraphElement.getAttribute("textIndent"); - paragraphTag.marginLeft = paragraphElement.getAttribute("marginLeft"); - paragraphTag.text = paragraphElement.getTextContent().trim(); - - allParagraphs.add(paragraphTag); - mapSectionFromArticle(articleMap, paragraphTag, paragraphElement); - } - } - - } - - private static void parseArticles(Element root, List allArticles, - Map articleMap, - Map sectionMap) { - NodeList artNodes = root.getElementsByTagName("ARTICLE"); - if(artNodes.getLength() > 0) { - for (int i = 0; i < artNodes.getLength(); i++) { - Element artElement = (Element) artNodes.item(i); - ArticleTag articleTag = new ArticleTag(); - articleTag.title = artElement.getAttribute("title"); - articleTag.paragraphs = new ArrayList<>(); - - allArticles.add(articleTag); - articleMap.put(artElement, articleTag); - mapSectionFromArticle(sectionMap, articleTag, artElement); - } - } - - } - - private static void mapSectionFromArticle(Map map, Tags tags, Element element) { - Element parentElement = (Element) element.getParentNode(); - Tags parentTag = map.get(parentElement); - if (parentTag != null) { - parentTag.addTag(tags); - } - } - - private static void parseSesctions(Element root, List allSections, Map sectionMap) { - NodeList secNodes = root.getElementsByTagName("SECTION"); - - if(secNodes.getLength() > 0) { - for (int i = 0; i < secNodes.getLength(); i++) { - Element secEl = (Element) secNodes.item(i); - SectionTag secDto = new SectionTag(); - secDto.title = secEl.getAttribute("title"); - secDto.articles = new ArrayList<>(); - - allSections.add(secDto); - sectionMap.put(secEl, secDto); - } - } - } - - private static Document parseXmlString(String xml) { - //TODO: 예외처리 후 삭제 - try { - return documentBuilderFactory.newDocumentBuilder() - .parse(new InputSource(new StringReader(xml))); - } catch (SAXException e) { - // System.out.println(xml); - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } catch (ParserConfigurationException e) { - //TODO DocumentBulider 생성 실패 - throw new RuntimeException(e); - } - } - - private static boolean isXmlNull(String xml) { - if (xml == null || xml.trim().isEmpty() || xml == "null") { - return true; - } else { - return false; - } - } - - private static class DocTag implements Tags { - public String title; - public String type; - public List sections; - - DocTag(Element root, List sections) { - this.title = root.getAttribute("title"); - this.type = root.getAttribute("type"); - this.sections = sections; - } - - @Override - public void addTag(Tags tags) { - sections.add((SectionTag) tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof DocTag; - } - } - - private static class SectionTag implements Tags { - public String title; - public List articles; - - @Override - public void addTag(Tags tags) { - articles.add((ArticleTag)tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof SectionTag; - } - } - - private static class ArticleTag implements Tags { - public String title; - public List paragraphs; - - @Override - public void addTag(Tags tags) { - paragraphs.add((ParagraphTag) tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof ArticleTag; - } - } - - public static class ParagraphTag implements Tags { - public String tagName; - public String textIndent; - public String marginLeft; - public String text; - - @Override - public void addTag(Tags tags) { - //TODO: addTag Exception 하위 없음 - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof ParagraphTag; - } - } - - public static interface Tags { - void addTag(Tags tags); - boolean equalsClass(Tags tags); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java deleted file mode 100644 index c059978..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugDetailRepositoryAdapter.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; - -import java.util.List; - -import org.springframework.stereotype.Component; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrugDetail; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class GovDrugDetailRepositoryAdapter implements DrugDetailRepositoryPort { - private final GovDrungDetailJpaRepository govDrungDetailJpaRepository; - - @Override - public List findAll(String code) { - return govDrungDetailJpaRepository.findAll(); - } - - @Override - public void saveAllAndFlush(List entities) { - govDrungDetailJpaRepository.saveAllAndFlush(entities); - } - - public GovDrugDetail toDomainFromEntity(GovDrugDetailEntity detail){ - return GovDrugDetail.builder() - .drugId(detail.getDrugId()) - .drugName(detail.getDrugName()) - .company(detail.getCompany()) - .permitDate(detail.getPermitDate()) - .isGeneral(detail.isGeneral()) - .materialInfo(detail.getMaterialInfo()) - .storeMethod(detail.getStoreMethod()) - .validTerm(detail.getValidTerm()) - .efficacy(detail.getEfficacy()) - .usage(detail.getUsage()) - .precaution(detail.getPrecaution()) - .build(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java deleted file mode 100644 index 3396760..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface GovDrugJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java deleted file mode 100644 index 43cb0f0..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrugRepositoryAdapter.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; - -import java.util.List; - -import org.springframework.stereotype.Component; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugRepositoryPort; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class GovDrugRepositoryAdapter implements DrugRepositoryPort { - private final GovDrugJpaRepository govDrungJpaRepository; - - @Override - public List findAll() { - return govDrungJpaRepository.findAll(); - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java deleted file mode 100644 index 45be610..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/adapter/out/persistence/GovDrungDetailJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface GovDrungDetailJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java deleted file mode 100644 index c32e37c..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugApprovalDetailScraperUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in; - -public interface DrugApprovalDetailScraperUseCase { - void requestUpdateRawData(); - - void requestUpdateAllRawData(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java deleted file mode 100644 index 51be1a2..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/in/DrugDataUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in; - -import java.util.List; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; - -public interface DrugDataUseCase { - List findAllRawDrug(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java deleted file mode 100644 index 522e50d..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailParserPort.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; - -public interface DrugDetailParserPort { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java deleted file mode 100644 index 8068322..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugDetailRepositoryPort.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; - -import java.util.List; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; - -public interface DrugDetailRepositoryPort { - - List findAll(String code); - void saveAllAndFlush(List entities); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java deleted file mode 100644 index cce5acf..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/port/out/DrugRepositoryPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out; - -import java.util.List; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; - -public interface DrugRepositoryPort { - List findAll(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java deleted file mode 100644 index 31f6ea5..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugApprovalDetailScraper.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -// import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser.MaterialParser; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.parser.XMLParser; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@Slf4j -@Component -@RequiredArgsConstructor -public class DrugApprovalDetailScraper implements DrugApprovalDetailScraperUseCase { - private final ObjectMapper objectMapper; - private final RestTemplate restTemplate; - private final ApiUriCompBuilder apiUriCompBuilder; - private final DrugDetailRepositoryPort drugDetailRepositoryPort; - - @Transactional - @Override - public void requestUpdateRawData() { - log.info("API 데이터 요청"); - String response = restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(2), String.class); - log.debug("API Response: {}", response); - - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List drugs = toListFromJson(items); - for (GovDrugDetailEntity drug : drugs) { - System.out.println(drug); - } - drugDetailRepositoryPort.saveAllAndFlush(drugs); - } - - @Transactional - @Override - public void requestUpdateAllRawData() { - int pageNo = 1; - int receivedCount = 0; - int savedCountWithoutDuplicates = 0; - - String response = fetchPage(pageNo); - int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); - - while (hasMoreData(receivedCount, totalCount)) { - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List drugs = toListFromJson(items); - receivedCount += drugs.size(); - - // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) - int uniqueItems = deduplicateByItemSeq(drugs); - savedCountWithoutDuplicates += uniqueItems; - - drugDetailRepositoryPort.saveAllAndFlush(drugs); - - log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", - pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); - - response = fetchPage(++pageNo); - } - - } - - private List toListFromJson(JsonNode items) { - - log.info("items 약품 객체로 맵핑"); - try { - List apiDataDrugDetails = toApiDetails(items); - for (int i = 0; i < apiDataDrugDetails.size(); i++) { - GovDrugDetailEntity drugDetail = apiDataDrugDetails.get(i); - JsonNode item = items.get(i); - log.debug("item seq: " + item.get("ITEM_SEQ").asText()); - - String materialRawData = item.get("MATERIAL_NAME").asText(); - String materialInfo = MaterialParser.parseMaterial(materialRawData); - drugDetail.changeMaterialInfo(materialInfo); - - String efficacyXmlText = item.get("EE_DOC_DATA").asText(); - String efficacy = XMLParser.toJson(efficacyXmlText); - drugDetail.changeEfficacy(efficacy); - - String usageXmlText = items.get(i).get("UD_DOC_DATA").asText(); - String usages = XMLParser.toJson(usageXmlText); - drugDetail.changeUsage(usages); - - String precautionxmlText = items.get(i).get("NB_DOC_DATA").asText(); - String precautions = XMLParser.toJson(precautionxmlText); - drugDetail.changePrecaution(precautions); - } - return apiDataDrugDetails; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private List toApiDetails(JsonNode items) { - try { - return objectMapper.readValue(items.toString(), - new TypeReference>() { - }); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - // private JsonNode toJsonFromXml(String usageXmlText) throws JsonProcessingException { - // XmlMapper xmlMapper = new XmlMapper(); - // - // JsonNode jsonNode = xmlMapper.readTree(usageXmlText) - // .path("SECTION") - // .path("ARTICLE"); - // return jsonNode; - // } - - // TODO: 추후 삭제 예정 - // private String replaceText(String text){ - // return text.replace("ᆞ ", "&") - // .replace("• ","") - // .replace("〜 ", "~"); - // } - - private int deduplicateByItemSeq(List drugs) { - // itemseq 기준으로 set에 저장 --> set은 중복 허용하지 않으므로 item seq 다 넣으면 알아서 중복 없이 저장됨 - Set uniqueItems = new HashSet<>(); - - for (GovDrugDetailEntity drug : drugs) { - uniqueItems.add(drug.getDrugId()); - } - return uniqueItems.size(); - } - - private String fetchPage(int pageNo) { - return restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(pageNo), String.class); - } - - private boolean hasMoreData(int receivedCount, int totalCount) { - return receivedCount < totalCount; - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java deleted file mode 100644 index dda18f5..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/DrugDataService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; - -import static java.util.stream.Collectors.*; - -import java.util.ArrayList; -import java.util.List; - -import org.springframework.stereotype.Service; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugEntity; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugDataUseCase; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugDetailRepositoryPort; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.out.DrugRepositoryPort; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model.GovDrug; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Slf4j -@Service -public class DrugDataService implements DrugDataUseCase { - private final DrugRepositoryPort repositoryPort; - - @Override - public List findAllRawDrug() { - log.info("findAllRawDrug called"); - return repositoryPort.findAll().stream() - .map(DrugDataMapper::toDomainFromEntity) - .collect(toList()); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java deleted file mode 100644 index eed53cc..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/application/service/RawDataParseService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.application.service; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import com.likelion.backendplus4.yakplus.scraper.drug.detail.application.port.in.DrugApprovalDetailScraperUseCase; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class RawDataParseService { - private final DrugApprovalDetailScraperUseCase drugApprovalDetailScraperUseCase; - - public void requestUpdateRawData() { - drugApprovalDetailScraperUseCase.requestUpdateRawData(); - } - - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java deleted file mode 100644 index 0d0999f..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/detail/domain/model/GovDrugDetail.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.detail.domain.model; - -import java.time.LocalDate; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.Builder; - -@Builder -public class GovDrugDetail { - private Long drugId; - private String drugName; - private String company; - private LocalDate permitDate; - private boolean isGeneral; - private String materialInfo; - private String storeMethod; - private String validTerm; - private String efficacy; - private String usage; - private String precaution; - - public JsonNode toJson(String json) { - try { - return new ObjectMapper().readValue(json, JsonNode.class); - } catch (JsonProcessingException e) { - //TODO 에러 로그 처리 필요합니다. - throw new RuntimeException(e); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java deleted file mode 100644 index bf714c9..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImg.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import lombok.ToString; - -@Entity -@ToString -public class ApiDataDrugImg { - @Id - @JsonProperty("ITEM_SEQ") - private Long seq; - - @JsonProperty("BIG_PRDT_IMG_URL") - private String imgUrl; -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java deleted file mode 100644 index 941b95a..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ApiDataDrugImgRepo.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ApiDataDrugImgRepo extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java deleted file mode 100644 index 674bd42..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/ImageScraper.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img; - -import java.net.URI; -import java.util.ArrayList; -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.persistence.GovDrugDetailEntity; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.ApiDataDrugJPARepo; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@Slf4j -@RequiredArgsConstructor -public class ImageScraper { - private final ApiUriCompBuilder uriCompBuilder; - private final RestTemplate restTemplate; - private final ApiDataDrugImgRepo imgRepo; - private final ObjectMapper objectMapper; - //TODO: 추후 삭제 - private final ApiDataDrugJPARepo detailRepo; - - @Transactional - public void getApiData(){ - log.info("의약품 개요 정보 API 호출 시작"); - - // URI uriForImgApi = uriCompBuilder.getUriForImgApi(); - // ApiDataDrugImg object = restTemplate.getForObject(uriForImgApi, - // new TypeReference>() {}); - - - //100번 반복 - List oldDatas = detailRepo.findAll(); - List imgDatas = new ArrayList<>(); - - for (GovDrugDetailEntity oldData : oldDatas) { - System.out.println("oldData = " + oldData.getDrugId()); - URI uri = uriCompBuilder.getUriForImgApiBySeq(oldData.getDrugId().toString()); - String response = restTemplate.getForObject(uri, String.class); - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - - try { - if(items.isArray()){ - for (JsonNode item : items) { - ApiDataDrugImg data = objectMapper.readValue(item.toString(), - ApiDataDrugImg.class); - System.out.println("data = " + data); - imgDatas.add(data); - } - } - } catch (JsonProcessingException e) { - log.error("객체 맵핑 실패"); - throw new RuntimeException(e); - } - } - System.out.println("imgDatas.size() = " + imgDatas.size()); - imgRepo.saveAllAndFlush(imgDatas); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java deleted file mode 100644 index 2536c3b..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/Test2Ctrl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/test2") -public class Test2Ctrl { - private final ImageScraper imageScraper; - - @GetMapping - public void test(){ - imageScraper.getApiData(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java deleted file mode 100644 index 6613988..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/controller/DragImageController.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img.controller; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.likelion.backendplus4.yakplus.scraper.drug.img.service.DrungImageGovScraper; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -public class DragImageController { - private final DrungImageGovScraper imageScraper; - - @GetMapping("/gov/api/parser/image/start") - public void test(){ - imageScraper.getApiData(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java deleted file mode 100644 index 403aeb3..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgEntity.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img.repository; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.ToString; - -@Entity -@ToString -@Table(name="API_DATA_DRUG_IMG") -public class ApiDataDrugImgEntity { - @Id - @JsonProperty("ITEM_SEQ") - private Long seq; - - @JsonProperty("BIG_PRDT_IMG_URL") - private String imgUrl; -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java deleted file mode 100644 index e73c9aa..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/repository/ApiDataDrugImgRepo.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ApiDataDrugImgRepo extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java deleted file mode 100644 index 7d297f3..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/scraper/drug/img/service/DrungImageGovScraper.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.likelion.backendplus4.yakplus.scraper.drug.img.service; - -import java.net.URI; -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.adapter.out.gov.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.scraper.drug.detail.ApiDataDrugJPARepo; -import com.likelion.backendplus4.yakplus.scraper.drug.img.repository.ApiDataDrugImgRepo; -import com.likelion.backendplus4.yakplus.scraper.drug.img.repository.ApiDataDrugImgEntity; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@Slf4j -@RequiredArgsConstructor -public class DrungImageGovScraper { - private final ApiUriCompBuilder uriCompBuilder; - private final RestTemplate restTemplate; - private final ApiDataDrugImgRepo imgRepo; - private final ObjectMapper objectMapper; - //TODO: 추후 삭제 - private final ApiDataDrugJPARepo detailRepo; - - @Transactional - public void getApiData(){ - log.info("의약품 개요 정보 API 호출 시작"); - - URI uriForImgApi = uriCompBuilder.getUriForImgApi(); - - String response = restTemplate.getForObject(uriForImgApi, String.class); - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List imgDatas = null; - try { - imgDatas = objectMapper.readValue(items.toString(), - new TypeReference>() {}); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - imgRepo.saveAllAndFlush(imgDatas); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java deleted file mode 100644 index 5b40d67..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/support/api/ApiUriCompBuilder.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.likelion.backendplus4.yakplus.support.api; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; - -/*** - * API 요청 URI 객체 생성 빌더 - * - * application.yml에서 주입되는 속성 값으로, - * API HOST, PATH를 확인해 URI 객체를 만듭니다. - * - * @since 2025-04-15 - * @author 함예정 - */ -@Component -public class ApiUriCompBuilder { - private final String SERVICE_KEY; - private final String HOST; - private final String API_DETAIL_PATH; - private final String API_IMG_PATH ; - private final String RESPONSE_TYPE; - - public ApiUriCompBuilder(@Value("${gov.host}") String host, - @Value("${gov.serviceKey}") String serviceKey, - @Value("${gov.path.detail}") String pathDetail, - @Value("${gov.path.img}") String pathImg, - @Value("${gov.type}") String type) { - this.HOST = host; - this.SERVICE_KEY = serviceKey; - this.API_DETAIL_PATH = pathDetail; - this.API_IMG_PATH = pathImg; - this.RESPONSE_TYPE = type; - } - - /*** - * 입력 받은 path를 반영해 URI 객체를 생성, 반환 - * - * @param path API 요청 경로 - * @return URI - * - * @since 2025-04-15 - * @author 함예정 - */ - private URI getUri(String path, int pageNo) { - return UriComponentsBuilder.newInstance() - .scheme("https") - .host(HOST) - .port(443) - .path(path) - .queryParam("serviceKey", SERVICE_KEY) - .queryParam("type", RESPONSE_TYPE) - .queryParam("pageNo", pageNo) - .queryParam("numOfRows", 100) - .build(true) - .toUri(); - } - - /*** - * 식품의약품안전처 의약품 제품 허가 상세 정보 URI 반환 - * @return URI 제품 허가 상세 정보 - * - * @since 2025-04-15 - * @author 함예정 - */ - public URI getUriForDetailApi(int pageNo) { - return getUri(API_DETAIL_PATH, pageNo); - } - - /*** - * 식품의약품안전처 의약품 제품 허가 목록 URI 반환 - * @return URI 제품 허가 목록 - * - * @since 2025-04-15 - * @author 함예정 - */ - public URI getUriForImgApi(int pageNo) { - return getUri(API_IMG_PATH, pageNo); - } - - // TODO 추후 삭제 - public URI getUriForImgApiBySeq(String seq) { - // 임시 URI - return UriComponentsBuilder.newInstance() - .scheme("https") - .host(HOST) - .port(443) - .path(API_IMG_PATH) - .queryParam("serviceKey", SERVICE_KEY) - .queryParam("type", RESPONSE_TYPE) - .queryParam("numOfRows", 1) - .queryParam("prdlst_Stdr_code", seq) - .build(true) - .toUri(); - - } - -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8f3ff5b..6b4b91a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,7 +24,6 @@ gov: host: apis.data.go.kr serviceKey: ${GOV_SERVICE_KEY} numOfRows: 100 - type: json path: detail: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05 img: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnInq06 From e74fc3ab7977a98ad21d07746fae253982993765 Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Fri, 25 Apr 2025 16:23:39 +0900 Subject: [PATCH 13/25] =?UTF-8?q?Feature/=20=EB=B2=A1=ED=84=B0=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: 벡터 검색 기능 개발 * ♻️ Refactor: 벡터 검색 기능 리팩토링 및 헥사고날 아키텍처 적용 * ♻️ Refactor: 헥사고날 아키텍쳐에 맞게 일부 객체의 반환값을 변경하였습니다, 컨트롤러에서 parameter가 아닌 객체로 값을 받도록 수정하였습니다. * ♻️ Refactor: 이제 헥사고날 아키텍처에 따라 도메인 객체가 서비스 계층 내부에만 존재합니다, 규칙에 맞추어 Exception처리가 추가되었습니다, 모든 클래스에 주석을 추가하였습니다. * ♻️ Refactor: Index와 Search 기능이 각각 독립적으로 실행될 수 있도록 분리하였습니다. --- build.gradle | 9 + .../application/port/in/IndexUseCase.java | 7 + .../application/service/DrugIndexer.java | 83 +++++++++ .../index/config/ElasticsearchConfig.java | 8 + .../yakplus/index/config/OpenAiConfig.java | 18 ++ .../yakplus/index/domain/model/Drug.java | 21 +++ .../index/exception/IndexException.java | 18 ++ .../index/exception/error/IndexErrorCode.java | 31 ++++ .../persistence/ElasticsearchDrugAdapter.java | 112 ++++++++++++ .../persistence/GovDrugRawDataAdapter.java | 115 ++++++++++++ .../persistence/OpenAIEmbeddingAdapter.java | 75 ++++++++ .../entity/GovDrugRawDataEntity.java | 36 ++++ .../repository/RawDataJpaRepository.java | 13 ++ .../controller/DrugController.java | 35 ++++ .../controller/dto/request/IndexRequest.java | 12 ++ .../port/in/SearchDrugUseCase.java | 10 ++ .../application/service/DrugSearcher.java | 115 ++++++++++++ .../common/exception/SearchException.java | 18 ++ .../exception/error/SearchErrorCode.java | 31 ++++ .../search/config/ElasticsearchConfig.java | 8 + .../yakplus/search/config/OpenAiConfig.java | 18 ++ .../yakplus/search/domain/model/Drug.java | 20 +++ .../persistence/ElasticsearchDrugAdapter.java | 163 ++++++++++++++++++ .../persistence/OpenAIEmbeddingAdapter.java | 75 ++++++++ .../controller/DrugController.java | 41 +++++ .../controller/dto/request/SearchRequest.java | 15 ++ .../dto/response/SearchResponse.java | 14 ++ src/main/resources/application.yml | 5 +- 28 files changed, 1125 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/SearchException.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/config/OpenAiConfig.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequest.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java diff --git a/build.gradle b/build.gradle index 172f63c..5eca490 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.17.10' + //ai + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M5' + // build.gradle if (project.hasProperty('env') && project.env == 'test') { dependencies { @@ -54,6 +57,12 @@ dependencies { } } +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:1.0.0-M5" + } +} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java new file mode 100644 index 0000000..1045a3e --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java @@ -0,0 +1,7 @@ +package com.likelion.backendplus4.yakplus.index.application.port.in; + +import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; + +public interface IndexUseCase { + void index(IndexRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java new file mode 100644 index 0000000..1d4e2fa --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java @@ -0,0 +1,83 @@ +package com.likelion.backendplus4.yakplus.index.application.service; + +import com.likelion.backendplus4.yakplus.index.application.port.in.IndexUseCase; +import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; +import com.likelion.backendplus4.yakplus.index.application.port.out.GovDrugRawDataPort; +import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; +import com.likelion.backendplus4.yakplus.index.domain.model.Drug; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 약품 색인(인덱싱) 작업을 수행하는 서비스 구현체 + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Service +@RequiredArgsConstructor +public class DrugIndexer implements IndexUseCase { + private final GovDrugRawDataPort govDrugRawDataPort; + private final DrugIndexRepositoryPort drugIndexRepositoryPort; + private static final String SORT_BY_PROPERTY = "itemSeq"; + + /** + * 요청으로 전달된 lastSeq, limit 정보를 바탕으로 RDB에서 데이터를 조회하고 + * ES 인덱스에 저장한다. + * + * @param request 색인 기준 및 개수 정보 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public void index(IndexRequest request) { + Pageable pageable = createPageable(request.limit()); + List drugs = fetchAndTransformRawData(request, pageable); + saveDrugs(drugs); + } + + /** + * RDB에서 lastSeq 이후의 원시 데이터를 조회하여 도메인 객체로 변환한다. + * + * @param request 색인 기준 정보 + * @param pageable 페이징 및 정렬 정보 + * @return 도메인 모델 리스트 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List fetchAndTransformRawData(IndexRequest request, Pageable pageable) { + return govDrugRawDataPort.fetchRawData(request.lastSeq(), pageable); + } + + /** + * limit 크기 및 itemSeq 오름차순 정렬 기준의 객체를 생성한다. + * + * @param limit 조회할 최대 건수 + * @return 페이징 객체 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private Pageable createPageable(int limit) { + return PageRequest.of(0, limit, Sort.by(SORT_BY_PROPERTY).ascending()); + } + + /** + * 조회된 도메인 객체들을 ES 인덱스에 저장 처리한다. + * + * @param drugs 저장할 도메인 모델 리스트 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private void saveDrugs(List drugs) { + drugIndexRepositoryPort.saveAll(drugs); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java new file mode 100644 index 0000000..11c5ef0 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.index.config; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ElasticsearchConfig { + // TODO: 필요 시 RestClient 빈 등록 +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java new file mode 100644 index 0000000..3813256 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.index.config; + +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAiConfig { + + @Value("${spring.ai.openai.api-key}") + private String apiKey; + + @Bean + public OpenAiApi openaiApi() { + return new OpenAiApi(apiKey); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java new file mode 100644 index 0000000..7b2b03f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java @@ -0,0 +1,21 @@ +package com.likelion.backendplus4.yakplus.index.domain.model; + +import lombok.*; + +import java.time.LocalDate; +import java.util.Map; + +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class Drug { + private Long itemSeq; + private String itemName; + private String entpName; + private String eeText; + private LocalDate itemPermitDate; + private Map materialName; + private String nbDocData; + private String udDocData; + private String storageMethod; + private String validTerm; + private String imgUrl; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java new file mode 100644 index 0000000..b2b08b9 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.index.exception; + +import com.likelion.backendplus4.yakplus.common.exception.CustomException; +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +public class IndexException extends CustomException { + private final ErrorCode errorCode; + + public IndexException(ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java new file mode 100644 index 0000000..eba0492 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java @@ -0,0 +1,31 @@ +package com.likelion.backendplus4.yakplus.index.exception.error; + +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum IndexErrorCode implements ErrorCode { + RAW_DATA_FETCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430001, "원시 데이터 조회 실패"), + ES_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430003, "Elasticsearch 저장 실패"), + EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"); + + private final HttpStatus status; + private final int code; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public int codeNumber() { + return code; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java new file mode 100644 index 0000000..8156015 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -0,0 +1,112 @@ +package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; +import com.likelion.backendplus4.yakplus.index.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.index.domain.model.Drug; +import com.likelion.backendplus4.yakplus.index.exception.IndexException; +import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; +import lombok.RequiredArgsConstructor; +import org.apache.http.entity.ContentType; +import org.apache.http.nio.entity.NStringEntity; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RestClient; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Elasticsearch를 통해 Drug 도메인 객체의 색인 기능을 제공하는 어댑터 클래스입니다. + * DrugIndexRepositoryPort를 구현하여 + * Elasticsearch 원격 호출을 캡슐화합니다. + * + * @modified 2025-04-24 + * @since 2025-04-22 + */ +@Component +@RequiredArgsConstructor +public class ElasticsearchDrugAdapter implements DrugIndexRepositoryPort { + private static final String DRUGS_INDEX = "drugs_v2"; + + private final RestClient restClient; + private final ObjectMapper objectMapper; + private final EmbeddingPort embeddingPort; + + /** + * 주어진 Drug 목록을 Elasticsearch에 일괄 저장한다. + * + * @param drugs 저장할 Drug 객체 리스트 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + @Override + public void saveAll(List drugs) { + drugs.forEach(this::saveDrug); + } + + /** + * 단일 Drug 객체를 Elasticsearch에 색인한다. + * 임베딩을 생성한 뒤, 문서 형태로 변환하여 색인 요청을 수행한다. + * 실패 시 SearchException을 발생시킨다. + * + * @param drug 저장할 Drug 도메인 객체 + * @throws IndexException 색인 처리 중 오류 발생 시 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + private void saveDrug(Drug drug) { + try { + float[] vector = embeddingPort.getEmbedding(drug.getEeText()); + + Map source = createDrugDocument(drug, vector); + String json = objectMapper.writeValueAsString(source); + + Request request = createIndexRequest(drug.getItemSeq(), json); + restClient.performRequest(request); + } catch (Exception e) { + //TODO: LOG ERROR 처리 요망 +// log(LogLevel.ERROR, "Elasticsearch 저장 실패", e); + throw new IndexException(IndexErrorCode.ES_SAVE_ERROR); + } + } + + /** + * Drug 객체와 임베딩 벡터를 기반으로 Elasticsearch 색인용 문서 필드 맵을 생성한다. + * + * @param drug 색인할 Drug 도메인 객체 + * @param vector 해당 객체에 대한 임베딩 벡터 + * @return Elasticsearch에 저장할 문서 필드 맵 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + private Map createDrugDocument(Drug drug, float[] vector) { + return Map.of( + "itemSeq", drug.getItemSeq().toString(), + "itemName", drug.getItemName(), + "entpName", drug.getEntpName(), + "eeText", drug.getEeText(), + "searchAll", drug.getEeText(), + "eeVector", vector + ); + } + + /** + * 지정된 인덱스와 문서 ID로 Elasticsearch 색인 요청 객체를 생성한다. + * + * @param itemSeq 문서 ID로 사용할 itemSeq 값 + * @param json 색인할 JSON 문자열 + * @return 색인 요청을 수행할 Request 객체 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + private Request createIndexRequest(Long itemSeq, String json) { + Request request = new Request("POST", "/" + DRUGS_INDEX + "/_doc/" + itemSeq); + request.setEntity(new NStringEntity(json, ContentType.APPLICATION_JSON)); + return request; + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java new file mode 100644 index 0000000..e16b9ad --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java @@ -0,0 +1,115 @@ +package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; + +import com.likelion.backendplus4.yakplus.index.application.port.out.GovDrugRawDataPort; +import com.likelion.backendplus4.yakplus.index.exception.IndexException; +import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; +import com.likelion.backendplus4.yakplus.index.infrastructure.entity.GovDrugRawDataEntity; +import com.likelion.backendplus4.yakplus.index.infrastructure.repository.RawDataJpaRepository; +import com.likelion.backendplus4.yakplus.index.domain.model.Drug; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 공공 API로부터 조회한 원시 약품 데이터를 JPA를 통해 가져와 + * 도메인 객체인 Drug로 변환하는 어댑터 클래스입니다. + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Component +@RequiredArgsConstructor +public class GovDrugRawDataAdapter implements GovDrugRawDataPort { + private final RawDataJpaRepository rawDataJpaRepository; + + /** + * 마지막으로 처리된 시퀀스 이후의 원시 데이터를 페이징 조건에 맞춰 조회하고 + * Drug 도메인 리스트로 변환하여 반환합니다. + * + * @param lastSeq 마지막 처리 시퀀스 (null이면 0부터 조회) + * @param pageable 페이징 및 정렬 정보 + * @return Drug 도메인 객체 리스트 + * @throws IndexException 데이터베이스 조회 실패 시 발생 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public List fetchRawData(Long lastSeq, Pageable pageable) { + long startSeq = getStartSeq(lastSeq); + List govDrugRawDataEntities = getGovDrugRawDataEntities(startSeq, pageable); + + return convertToDrugDomains(govDrugRawDataEntities); + } + + /** + * lastSeq가 null일 경우 0으로 치환하여 조회 시작점을 결정합니다. + * + * @param lastSeq 마지막 처리 시퀀스 + * @return 실제 조회 시작 시퀀스 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private Long getStartSeq(Long lastSeq) { + return (lastSeq == null ? 0L : lastSeq); + } + + /** + * JPA 레포지토리를 이용해 itemSeq 기준으로 정렬된 데이터를 조회합니다. + * + * @param lastSeq 마지막으로 조회된 Seq + * @param pageable 페이징 및 정렬 정보 + * @return 조회된 GovDrugRawDataEntity 리스트 + * @throws IndexException 조회 중 예외가 발생하면 SearchErrorCode.RAW_DATA_FETCH_ERROR로 래핑하여 던집니다. + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List getGovDrugRawDataEntities(Long lastSeq, Pageable pageable) { + try { + return rawDataJpaRepository.findByItemSeqGreaterThanOrderByItemSeqAsc(lastSeq, pageable); + } catch (Exception e) { + //TODO: LOG ERROR 처리 요망 +// log(LogLevel.ERROR, "MySQL 데이터 조회 실패", e); + throw new IndexException(IndexErrorCode.RAW_DATA_FETCH_ERROR); + } + + } + + /** + * 조회된 엔티티 리스트를 Drug 도메인 객체 리스트로 변환합니다. + * + * @param rawData GovDrugRawDataEntity 리스트 + * @return Drug 도메인 객체 리스트 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List convertToDrugDomains(List rawData) { + return rawData.stream() + .map(this::mapToDrugDomain) + .collect(Collectors.toList()); + } + + /** + * 단일 GovDrugRawDataEntity를 Drug 도메인 객체로 매핑합니다. + * + * @param entity 변환할 GovDrugRawDataEntity + * @return 변환된 Drug 도메인 객체 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private Drug mapToDrugDomain(GovDrugRawDataEntity entity) { + return Drug.builder() + .itemSeq(entity.getItemSeq()) + .itemName(entity.getItemName()) + .entpName(entity.getEntpName()) + .eeText(entity.getEeDocData()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java new file mode 100644 index 0000000..bfae023 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java @@ -0,0 +1,75 @@ +package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; + +import com.likelion.backendplus4.yakplus.index.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.index.exception.IndexException; +import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * OpenAI 임베딩 API를 호출하여 텍스트에 대한 벡터 임베딩을 생성하는 어댑터 클래스입니다. + * EmbeddingPort 인터페이스를 구현하며, 오류 발생 시 SearchException으로 래핑합니다. + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Component +@RequiredArgsConstructor +public class OpenAIEmbeddingAdapter implements EmbeddingPort { + private final OpenAiApi openAiApi; + private static final String EMBEDDING_MODEL = "text-embedding-3-small"; + + /** + * 주어진 텍스트를 OpenAI 임베딩 모델에 전달하여 벡터 값을 반환합니다. + * 내부에서 OpenAiEmbeddingModel을 생성하고 retry 템플릿을 적용합니다. + * API 호출 중 예외가 발생하면 SearchException(EMBEDDING_API_ERROR)을 던집니다. + * + * @param text 벡터화할 입력 텍스트 + * @return float 배열 형태의 임베딩 벡터 + * @throws IndexException EMBEDDING_API_ERROR 코드로 래핑하여 발생 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public float[] getEmbedding(String text) { + try { + OpenAiEmbeddingModel embeddingModel = createEmbeddingModel(); + EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text)); + return response.getResults().getFirst().getOutput(); + } catch (Exception e) { + //TODO: LOG ERROR 처리 요망 +// log(LOGLEVEL.ERROR, "임베딩 API에서 문제가 발생하였습니다., e); + throw new IndexException(IndexErrorCode.EMBEDDING_API_ERROR); + } + } + + /** + * OpenAiEmbeddingModel 인스턴스를 생성하여 반환합니다. + * MetadataMode와 모델 이름, RetryUtils 설정이 포함됩니다. + * + * @return 초기화된 OpenAiEmbeddingModel 객체 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private OpenAiEmbeddingModel createEmbeddingModel() { + return new OpenAiEmbeddingModel( + openAiApi, + MetadataMode.EMBED, + OpenAiEmbeddingOptions.builder() + .model(EMBEDDING_MODEL) + .build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java new file mode 100644 index 0000000..a86b91d --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java @@ -0,0 +1,36 @@ +package com.likelion.backendplus4.yakplus.index.infrastructure.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "gov_drug_detail") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GovDrugRawDataEntity { + @Id + private Long itemSeq; + private Boolean etcOtcCode; + private LocalDate itemPermitDate; + @Column(columnDefinition = "json") + private String eeDocData; + private String entpName; + private String itemName; + @Column(columnDefinition = "json") + private String materialName; + @Column(columnDefinition = "json") + private String nbDocData; + private String storageMethod; + @Column(columnDefinition = "json") + private String udDocData; + private String validTerm; +// private String imgUrl; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java new file mode 100644 index 0000000..3e276fe --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java @@ -0,0 +1,13 @@ +package com.likelion.backendplus4.yakplus.index.infrastructure.repository; + +import com.likelion.backendplus4.yakplus.index.infrastructure.entity.GovDrugRawDataEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RawDataJpaRepository extends JpaRepository { + List findByItemSeqGreaterThanOrderByItemSeqAsc(Long lastSeq, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java new file mode 100644 index 0000000..929b26e --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java @@ -0,0 +1,35 @@ +package com.likelion.backendplus4.yakplus.index.presentation.controller; + +import com.likelion.backendplus4.yakplus.index.application.port.in.IndexUseCase; +import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; +import lombok.RequiredArgsConstructor; +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; + +/** + * 약품 인덱싱 API 엔드포인트를 제공하는 컨트롤러 클래스 + * + * @modified 2025-04-25 + * @since 2025-04-22 + */ +@RestController +@RequestMapping("/api/drugs/index") +@RequiredArgsConstructor +public class DrugController { + private final IndexUseCase indexUseCase; + + /** + * 색인 생성 요청을 처리한다. + * + * @param request 인덱싱 범위 및 개수 정보를 담은 요청 객체 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + @PostMapping("/index") + public void index(@RequestBody IndexRequest request) { + indexUseCase.index(request); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java new file mode 100644 index 0000000..e096a37 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java @@ -0,0 +1,12 @@ +package com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request; + +/** + * 인덱싱 요청 정보 DTO + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +public record IndexRequest( + Long lastSeq, + int limit) { +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java new file mode 100644 index 0000000..052df95 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -0,0 +1,10 @@ +package com.likelion.backendplus4.yakplus.search.application.port.in; + +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; + +import java.util.List; + +public interface SearchDrugUseCase { + List search(SearchRequest searchRequest); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java new file mode 100644 index 0000000..f7ea534 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -0,0 +1,115 @@ +package com.likelion.backendplus4.yakplus.search.application.service; + +import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; +import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 사용자 검색 요청을 처리하고, 벡터 유사도 및 텍스트 검색을 통해 + * 결과를 반환하는 서비스 구현체 + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Service +@RequiredArgsConstructor +public class DrugSearcher implements SearchDrugUseCase { + private final DrugSearchRepositoryPort drugSearchRepositoryPort; + private final EmbeddingPort embeddingPort; + + /** + * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 + * 리스트로 매핑하여 반환한다. + * + * @param request 검색어 및 페이지 정보 + * @return 검색 결과 DTO 리스트 + * @throws SearchException 검색어가 유효하지 않은 경우 발생 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public List search(SearchRequest request) { + validateQuery(request.query()); + float[] embeddings = generateEmbeddings(request.query()); + return searchDrugs(request, embeddings); + } + + /** + * 검색어가 null이거나 빈 문자열인지 검사하고, + * 유효하지 않으면 SearchException을 던진다. + * + * @param query 검색어 문자열 + * @throws SearchException INVALID_QUERY 코드와 함께 발생 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private void validateQuery(String query) { + if (query == null || query.isEmpty()) { + //TODO: LOG WARN 처리 요망 +// log(LogLevel.warn, "검색 쿼리가 비어있거나 null입니다."); + throw new SearchException(SearchErrorCode.INVALID_QUERY); + } + } + + /** + * OpenAI API를 통해 검색어의 임베딩 벡터를 생성한다. + * + * @param query 검색어 문자열 + * @return 임베딩 벡터 배열 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private float[] generateEmbeddings(String query) { + return embeddingPort.getEmbedding(query); + } + + /** + * 생성된 임베딩 벡터와 검색 요청 정보를 이용해 Elasticsearch에서 조회를 수행하고, + * 도메인 모델 리스트를 SearchResponse DTO 리스트로 변환해 반환한다. + * + * @param searchRequest 검색어 및 페이지/사이즈 정보가 담긴 DTO + * @param embeddings 검색어에 대한 임베딩 벡터 배열 + * @return SearchResponse 객체 리스트 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List searchDrugs(SearchRequest searchRequest, float[] embeddings) { + List drugs = drugSearchRepositoryPort.searchBySymptoms(searchRequest.query(), embeddings, searchRequest.size(), searchRequest.page() * searchRequest.size()); + return mapToDrugDomain(drugs); + } + + /** + * 도메인 모델 객체 리스트를 받아서, 각 객체의 필드를 추출한 후 + * SearchResponse DTO로 매핑하여 리스트로 반환한다. + * + * @param drugs 도메인 모델 Drug 객체 리스트 + * @return SearchResponse DTO 리스트 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List mapToDrugDomain(List drugs) { + return drugs.stream() + .map(d -> new SearchResponse( + d.getItemSeq(), + d.getItemName(), + d.getEntpName(), + d.getEeText() + )) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/SearchException.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/SearchException.java new file mode 100644 index 0000000..dc96606 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/SearchException.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.search.common.exception; + +import com.likelion.backendplus4.yakplus.common.exception.CustomException; +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +public class SearchException extends CustomException { + private final ErrorCode errorCode; + + public SearchException(ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java new file mode 100644 index 0000000..cd253f2 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -0,0 +1,31 @@ +package com.likelion.backendplus4.yakplus.search.common.exception.error; + +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum SearchErrorCode implements ErrorCode { + INVALID_QUERY(HttpStatus.BAD_REQUEST, 140001, "검색어를 입력해주세요"), + ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), + EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"); + + private final HttpStatus status; + private final int code; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public int codeNumber() { + return code; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java new file mode 100644 index 0000000..53e8dc4 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.search.config; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ElasticsearchConfig { + // TODO: 필요 시 RestClient 빈 등록 +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/config/OpenAiConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/search/config/OpenAiConfig.java new file mode 100644 index 0000000..6d5ec09 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/config/OpenAiConfig.java @@ -0,0 +1,18 @@ +package com.likelion.backendplus4.yakplus.search.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.ai.openai.api.OpenAiApi; + +@Configuration +public class OpenAiConfig { + + @Value("${spring.ai.openai.api-key}") + private String apiKey; + + @Bean + public OpenAiApi openaiApi() { + return new OpenAiApi(apiKey); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java new file mode 100644 index 0000000..1b65433 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java @@ -0,0 +1,20 @@ +package com.likelion.backendplus4.yakplus.search.domain.model; + +import lombok.*; +import java.time.LocalDate; +import java.util.Map; + +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class Drug { + private Long itemSeq; + private String itemName; + private String entpName; + private String eeText; + private LocalDate itemPermitDate; + private Map materialName; + private String nbDocData; + private String udDocData; + private String storageMethod; + private String validTerm; + private String imgUrl; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java new file mode 100644 index 0000000..397e089 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -0,0 +1,163 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import lombok.RequiredArgsConstructor; +import org.apache.http.entity.ContentType; +import org.apache.http.nio.entity.NStringEntity; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. + * DrugSearchRepositoryPort를 구현하여 + * Elasticsearch 원격 호출을 캡슐화합니다. + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Component +@RequiredArgsConstructor +public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { + private static final String SEARCH_INDEX = "drugs"; + + private final RestClient restClient; + private final ObjectMapper objectMapper; + + /** + * 주어진 쿼리 및 임베딩 벡터를 사용해 Elasticsearch에서 검색을 수행하고, + * 도메인 모델 리스트를 반환한다. 실패 시 SearchException을 발생시킨다. + * + * @param query 사용자 검색어 + * @param vector 검색어 임베딩 벡터 + * @param size 한 페이지에 조회할 문서 수 + * @param from 조회 시작 오프셋 + * @return 검색된 Drug 도메인 객체 리스트 + * @throws SearchException 검색 처리 중 오류 발생 시 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public List searchBySymptoms(String query, float[] vector, int size, int from) { + try { + String esQuery = buildSearchQuery(query, vector, size, from); + Response response = executeSearch(esQuery); + List results = parseSearchResults(response); + return results; + + } catch (Exception e) { + //TODO: LOG ERROR 처리 요망 +// log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + query, e); + throw new SearchException(SearchErrorCode.ES_SEARCH_ERROR); + } + } + + /** + * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. + * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. + * + * @param query 검색어 + * @param vector 검색어 임베딩 벡터 + * @param size 한 페이지에 조회할 문서 수 + * @param from 조회 시작 오프셋 + * @return Elasticsearch에 전달할 쿼리 JSON 문자열 + * @throws IOException JSON 직렬화 과정에서 오류 발생 시 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private String buildSearchQuery(String query, float[] vector, int size, int from) throws IOException { + String vectorJson = objectMapper.writeValueAsString(vector); + return """ + { + "from": %d, + "size": %d, + "query": { + "bool": { + "must": { + "script_score": { + "query": { "match_all": {} }, + "script": { + "inline": "cosineSimilarity(params.queryVector, 'eeVector') + 1.0", + "lang": "painless", + "params": { "queryVector": %s } + } + } + }, + "should": [ + { + "match": { + "searchAll": { + "query": "%s", + "fuzziness": "AUTO", + "boost": 0.2 + } + } + } + ] + } + } + } + """.formatted(from, size, vectorJson, query.replace("\"", "\\\\\"")); + } + + /** + * Elasticsearch에 빌드된 쿼리를 전송하여 검색 결과 Response를 반환한다. + * + * @param esQuery Elasticsearch Query DSL JSON 문자열 + * @return Elasticsearch 응답 객체 + * @throws IOException 요청 전송 또는 응답 처리 중 오류 발생 시 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private Response executeSearch(String esQuery) throws IOException { + Request request = new Request("GET", "/" + SEARCH_INDEX + "/_search"); + request.setEntity(new NStringEntity(esQuery, ContentType.APPLICATION_JSON)); + return restClient.performRequest(request); + } + + /** + * Elasticsearch 검색 결과 Response에서 hits 배열을 파싱해 + * Drug 도메인 객체 리스트로 변환한다. + * + * @param response Elasticsearch 검색 응답 객체 + * @return 파싱된 Drug 도메인 객체 리스트 + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private List parseSearchResults(Response response) throws IOException { + InputStream is = response.getEntity().getContent(); + JsonNode hits = objectMapper.readTree(is).path("hits").path("hits"); + + List results = new ArrayList<>(); + for (JsonNode hit : hits) { + JsonNode source = hit.path("_source"); + Drug drug = Drug.builder() + .itemSeq(source.path("itemSeq").asLong()) + .itemName(source.path("itemName").asText()) + .entpName(source.path("entpName").asText()) + .eeText(source.path("eeText").asText()) + .build(); + results.add(drug); + } + return results; + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java new file mode 100644 index 0000000..298fa3b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java @@ -0,0 +1,75 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; + +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * OpenAI 임베딩 API를 호출하여 텍스트에 대한 벡터 임베딩을 생성하는 어댑터 클래스입니다. + * EmbeddingPort 인터페이스를 구현하며, 오류 발생 시 SearchException으로 래핑합니다. + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +@Component +@RequiredArgsConstructor +public class OpenAIEmbeddingAdapter implements EmbeddingPort { + private final OpenAiApi openAiApi; + private static final String EMBEDDING_MODEL = "text-embedding-3-small"; + + /** + * 주어진 텍스트를 OpenAI 임베딩 모델에 전달하여 벡터 값을 반환합니다. + * 내부에서 OpenAiEmbeddingModel을 생성하고 retry 템플릿을 적용합니다. + * API 호출 중 예외가 발생하면 SearchException(EMBEDDING_API_ERROR)을 던집니다. + * + * @param text 벡터화할 입력 텍스트 + * @return float 배열 형태의 임베딩 벡터 + * @throws SearchException EMBEDDING_API_ERROR 코드로 래핑하여 발생 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + @Override + public float[] getEmbedding(String text) { + try { + OpenAiEmbeddingModel embeddingModel = createEmbeddingModel(); + EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text)); + return response.getResults().getFirst().getOutput(); + } catch (Exception e) { + //TODO: LOG ERROR 처리 요망 +// log(LOGLEVEL.ERROR, "임베딩 API에서 문제가 발생하였습니다., e); + throw new SearchException(SearchErrorCode.EMBEDDING_API_ERROR); + } + } + + /** + * OpenAiEmbeddingModel 인스턴스를 생성하여 반환합니다. + * MetadataMode와 모델 이름, RetryUtils 설정이 포함됩니다. + * + * @return 초기화된 OpenAiEmbeddingModel 객체 + * @author 정안식 + * @since 2025-04-22 + * @modified 2025-04-24 + */ + private OpenAiEmbeddingModel createEmbeddingModel() { + return new OpenAiEmbeddingModel( + openAiApi, + MetadataMode.EMBED, + OpenAiEmbeddingOptions.builder() + .model(EMBEDDING_MODEL) + .build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java new file mode 100644 index 0000000..40090bb --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -0,0 +1,41 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller; + +import com.likelion.backendplus4.yakplus.response.ApiResponse; +import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import lombok.RequiredArgsConstructor; +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; + +import java.util.List; + +/** + * 약품 검색 API 엔드포인트를 제공하는 컨트롤러 클래스 + * + * @modified 2025-04-25 + * @since 2025-04-22 + */ +@RestController +@RequestMapping("/api/drugs") +@RequiredArgsConstructor +public class DrugController { + private final SearchDrugUseCase searchDrugUseCase; + + /** + * 약품 검색 요청을 처리하여 검색 결과를 반환한다. + * + * @param searchRequest 검색어, 페이지 및 페이지 크기를 담은 요청 객체 + * @return 검색 결과 리스트를 포함한 표준 응답 구조 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + @PostMapping("/search") + public ResponseEntity>> search(@RequestBody SearchRequest searchRequest) { + return ApiResponse.success(searchDrugUseCase.search(searchRequest)); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequest.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequest.java new file mode 100644 index 0000000..29d0f90 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequest.java @@ -0,0 +1,15 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request; + +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 검색 요청 정보 DTO + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +public record SearchRequest( + String query, + int page, + int size) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java new file mode 100644 index 0000000..e8b346f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java @@ -0,0 +1,14 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; + +/** + * 검색 결과 정보 DTO + * + * @since 2025-04-22 + * @modified 2025-04-24 + */ +public record SearchResponse( + Long itemSeq, + String itemName, + String entpName, + String eeText) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6b4b91a..4ca476e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,7 @@ spring: + ai: + openai: + api-key: ${OPENAI_API_KEY} application: name: yakplus elasticsearch: @@ -28,4 +31,4 @@ gov: detail: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05 img: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnInq06 server: - port: 8084 + port: 8084 \ No newline at end of file From 30e935d709d21b6ffcb89639a118bee7575117e8 Mon Sep 17 00:00:00 2001 From: chanbyeong <122460524+chanbyoung@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:25:22 +0900 Subject: [PATCH 14/25] =?UTF-8?q?=E2=9C=A8=20Feature:=20ee-doc=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: ee_doc 파일 인덱싱 기능 구현 * ✨ Feat: 자동완성 검색 기능 구현 * ♻️ Refactor: 핵사고날 구조에 적용 * ♻️ Refactor: 자동완성 기능 아키텍쳐 적용 및 예외처리 추가 * ♻️ Refactor: 인덱싱 저장 로직 청크 단위로 저장하도록 변경 * ♻️ Refactor: List값 응답 객체에 담아서 보내도록 변경 * ♻️ Refactor: Mapper 클래스 분리 * 💄 Style: JavaDoc 주석문 추가 * ✨ Feat: 증상기반 약품 검색 기능 개발 * 💄 Style: Mapper 클래스 주석문 추가 --- .../service/DrugSymptomService.java | 97 +++++++++++++ .../drug/domain/model/DrugSymptom.java | 16 +++ .../yakplus/drug/domain/model/GovDrug.java | 36 ++--- .../drug/exception/EsSuggestException.java | 17 +++ .../drug/exception/error/EsErrorCode.java | 32 +++++ .../adapter/DrugSymptomEsAdapter.java | 128 ++++++++++++++++++ .../adapter/GovDrugJpaAdapter.java | 42 ++++++ .../document/DrugSymptomDocument.java | 35 +++++ .../elasticsearch/DrugSymptomRepository.java | 8 ++ .../support/mapper/DrugDataMapper.java | 4 +- .../support/mapper/SymptomMapper.java | 91 +++++++++++++ .../support/parser/JsonTextParser.java | 76 +++++++++++ .../support/parser/SymptomTextParser.java | 61 +++++++++ .../controller/DrugSymptomController.java | 77 +++++++++++ .../controller/dto/DrugSymptomList.java | 8 ++ .../controller/dto/DrugSymptomResponse.java | 11 ++ .../dto/DrugSymptomSearchListResponse.java | 8 ++ .../temp/api/EEDocIndexController.java | 31 +++++ .../yakplus/temp/api/SymptomController.java | 50 +++++++ .../temp/application/EEDocIndexService.java | 97 +++++++++++++ .../yakplus/temp/dao/EEDocRepository.java | 9 ++ .../temp/entity/document/EEDocDocument.java | 36 +++++ 22 files changed, 951 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java new file mode 100644 index 0000000..140fd98 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java @@ -0,0 +1,97 @@ +package com.likelion.backendplus4.yakplus.drug.application.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.DrugSymptomEsAdapter; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.GovDrugJpaAdapter; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomList; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomResponse; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomSearchListResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 약품 증상 데이터를 처리하는 서비스입니다. + * @sice 2025-04-24 + * @modified 2025-04-25 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DrugSymptomService { + + private static final int CHUNK_SIZE = 1_000; + + private final GovDrugJpaAdapter drugJpaAdapter; + private final DrugSymptomEsAdapter symptomAdapter; + + /** + * DB에서 약품 데이터를 페이징으로 가져와 Elasticsearch에 일괄 색인합니다. + * 각 페이지는 CHUNK_SIZE만큼 처리되며, 모든 데이터를 순차적으로 색인합니다. + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + public void indexAll() { + int page = 0; + Page drugPage; + + do { + // 1. 페이징으로 DB에서 한 청크 가져오기 + drugPage = drugJpaAdapter.findAllDrugs(PageRequest.of(page, CHUNK_SIZE)); + + // 2. 도메인 → ES Document 변환 + List docs = drugPage.stream() + .map(SymptomMapper::toDocument) // 내부에서 예외 처리 됨 + .toList(); + + // 3. 청크별 ES에 색인 + symptomAdapter.saveAll(docs); + + // 4. 다음 1000개 값 루프 + page++; + } while (drugPage.hasNext()); + } + + /** + * 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다. + * Elasticsearch에서 Suggest API 등을 활용하여 추천 결과를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + public DrugSymptomSearchListResponse getSymptomAutoComplete(String q) { + return new DrugSymptomSearchListResponse(symptomAdapter.getSearchAutoCompleteResponse(q)); + } + + /** + * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 + * @param size 페이지 당 문서 수 + * @return 중복 제거된 약품명 리스트 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public DrugSymptomList searchDrugNamesBySymptom(String q, int page, int size) { + List drugSymptomResponses = symptomAdapter.searchDocsBySymptom(q, page, size) + .stream() + .map(SymptomMapper::toResponse) + .toList(); + + return new DrugSymptomList(drugSymptomResponses); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java new file mode 100644 index 0000000..fc9b500 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java @@ -0,0 +1,16 @@ +package com.likelion.backendplus4.yakplus.drug.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DrugSymptom { + private Long drugId; + private String drugName; + private String symptom; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java index aa20b2d..75ed781 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java @@ -53,24 +53,24 @@ public List getMaterialInfo() { return matrerials; } - public List getEfficacy() { - List efficacys = new ArrayList<>(); - try { - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode json = objectMapper.readTree(this.efficacy); - for (JsonNode section : json.get("sections")) { - for (JsonNode article : section.get("articles")) { - for (JsonNode paragraph : article.get("paragraphs")) { - efficacys.add(paragraph.get("text").asText()); - } - } - } - } catch (JsonProcessingException e) { - //TODO: 예외처리 - throw new RuntimeException(e); - } - return efficacys; - } + // public List getEfficacy() { + // List efficacys = new ArrayList<>(); + // try { + // ObjectMapper objectMapper = new ObjectMapper(); + // JsonNode json = objectMapper.readTree(this.efficacy); + // for (JsonNode section : json.get("sections")) { + // for (JsonNode article : section.get("articles")) { + // for (JsonNode paragraph : article.get("paragraphs")) { + // efficacys.add(paragraph.get("text").asText()); + // } + // } + // } + // } catch (JsonProcessingException e) { + // //TODO: 예외처리 + // throw new RuntimeException(e); + // } + // return efficacys; + // } public Map> getPrecaution() { ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java new file mode 100644 index 0000000..fbf5156 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java @@ -0,0 +1,17 @@ +package com.likelion.backendplus4.yakplus.drug.exception; + +import com.likelion.backendplus4.yakplus.common.exception.CustomException;import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +public class EsSuggestException extends CustomException { + private final ErrorCode errorCode; + + public EsSuggestException(ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java new file mode 100644 index 0000000..74e9a28 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java @@ -0,0 +1,32 @@ +package com.likelion.backendplus4.yakplus.drug.exception.error; + +import org.springframework.http.HttpStatus; + +import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum EsErrorCode implements ErrorCode { + ES_SUGGEST_SEARCH_FAIL(440001, HttpStatus.INTERNAL_SERVER_ERROR, "검색어 자동완성에 실패했습니다."), + ES_SEARCH_FAIL(440002, HttpStatus.INTERNAL_SERVER_ERROR, "증상 검색에 실패했습니다."); + + private final int codeNumber; + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public int codeNumber() { + return codeNumber; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java new file mode 100644 index 0000000..a7f9535 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java @@ -0,0 +1,128 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter; + + +import static org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName.*; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.likelion.backendplus4.yakplus.drug.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.drug.exception.EsSuggestException; +import com.likelion.backendplus4.yakplus.drug.exception.error.EsErrorCode; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.elasticsearch.DrugSymptomRepository; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; +import co.elastic.clients.elasticsearch.core.search.Hit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Elasticsearch를 통해 약품 증상 문서를 색인하고, + * 자동완성 제안 결과를 제공하는 어댑터입니다. + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class DrugSymptomEsAdapter { + + private final DrugSymptomRepository symptomRepository; + private final ElasticsearchClient esClient; + + /** + * 주어진 증상 문서 리스트를 Elasticsearch에 색인합니다. + * + * @param docs 색인할 DrugSymptomDocument 객체 리스트 + * @author 박찬병 + * @modified 2025-04-25 + * @since 2025-04-24 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAll(List docs) { + symptomRepository.saveAll(docs); + } + + /** + * 사용자 입력 키워드를 바탕으로 Elasticsearch Suggest API를 호출해 + * 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 추천 키워드 리스트 + * @throws EsSuggestException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @modified 2025-04-25 + * @since 2025-04-24 + */ + public List getSearchAutoCompleteResponse(String q) { + SearchResponse resp; + try { + resp = esClient.search(s -> s + .index("eedoc") + .suggest(su -> su + .suggesters("symp_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("symptomSuggester") + .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 + .size(20) + ) + ) + ) + , Void.class); + } catch (IOException e) { + throw new EsSuggestException(EsErrorCode.ES_SUGGEST_SEARCH_FAIL); + } + + // Suggest 파싱 + return resp.suggest().get("symp_sugg") + .get(0).completion().options().stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); + } + + /** + * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 페이지 당 문서 수 + * @return 증상 문서 리스트 + * @throws EsSuggestException 검색 중 오류 발생 시 + */ + public List searchDocsBySymptom(String q, int page, int size) { + try { + SearchResponse resp = esClient.search(s -> s + .index(INDEX) + .from(page * size) + .size(size) + .query(qb -> qb + .multiMatch(mm -> mm + .fields("symptom") + .query(q) + .fuzziness("AUTO") + ) + ), DrugSymptomDocument.class); + return resp.hits().hits().stream() + .map(Hit::source) + .filter(Objects::nonNull) + .map(SymptomMapper::toDomain) + .toList(); + } catch (IOException e) { + log.error("ES 증상 문서 검색 실패: q={}", q, e); + throw new EsSuggestException(EsErrorCode.ES_SEARCH_FAIL); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java new file mode 100644 index 0000000..0ff761d --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java @@ -0,0 +1,42 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter; + + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugJpaRepository; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; + +import lombok.RequiredArgsConstructor; + +/** + * DB에서 약품 데이터를 다루는 어댑터입니다. + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ +@Component +@RequiredArgsConstructor +public class GovDrugJpaAdapter { + + private final GovDrugJpaRepository drugJpaRepository; + + /** + * 주어진 Pageable 정보에 따라 DB에서 한 페이지 분량의 GovDrugEntity를 조회하고, + * 각 엔티티를 도메인 모델(GovDrug)로 변환하여 Page 형태로 반환합니다. + * + * @param pageable 조회할 페이지 번호와 크기를 포함하는 Pageable 객체 + * @return 페이지 단위로 변환된 GovDrug 도메인 객체의 Page + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + * + */ + public Page findAllDrugs(Pageable pageable) { + return drugJpaRepository.findAll(pageable) + .map(DrugDataMapper::toDomainFromEntity); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java new file mode 100644 index 0000000..471f34b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java @@ -0,0 +1,35 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.CompletionField; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Document(indexName = "eedoc") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DrugSymptomDocument { + + @Id + @Field(type = FieldType.Keyword, name = "ITEM_SEQ") + private Long drugId; + + @Field(type = FieldType.Text, name = "ITEM_NAME") + private String drugName; + + @Field(type = FieldType.Text, name = "symptom", analyzer = "only_nouns") + private String symptom; + + @CompletionField(analyzer = "symptom_autocomplete") + private List symptomSuggester; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java new file mode 100644 index 0000000..bf3e230 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.elasticsearch; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; + +public interface DrugSymptomRepository extends ElasticsearchRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java index c5e8b83..463e0d6 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java @@ -4,7 +4,7 @@ import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; public class DrugDataMapper { - public static GovDrug toDomainFromEntity(GovDrugEntity e){ + public static GovDrug toDomainFromEntity(GovDrugEntity e) { return GovDrug.builder() .drugId(e.getId()) .drugName(e.getDrugName()) @@ -20,4 +20,6 @@ public static GovDrug toDomainFromEntity(GovDrugEntity e){ .imageUrl(e.getImageUrl()) .build(); } + + } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java new file mode 100644 index 0000000..311139a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java @@ -0,0 +1,91 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper; + +import java.io.IOException; +import java.util.List; + +import com.likelion.backendplus4.yakplus.drug.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.drug.exception.ScraperException; +import com.likelion.backendplus4.yakplus.drug.exception.error.ScraperErrorCode; +import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.JsonTextParser; +import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.SymptomTextParser; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomResponse; + +/** + * 증상 관련 Document를 다루는 매퍼 클래스입니다. + * + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ +public class SymptomMapper { + + /** + * 주어진 GovDrug 도메인 객체를 기반으로 ES 색인용 DrugSymptomDocument로 변환합니다. + * 내부에서 JSON 파싱 및 전처리 로직을 실행하며, 파싱 실패 시 ScraperException을 던집니다. + * + * @param entity 변환 대상 GovDrug 도메인 객체 + * @return 변환된 DrugSymptomDocument 객체 + * @throws ScraperException JSON 파싱 실패 시 발생 + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public static DrugSymptomDocument toDocument(GovDrug entity) { + List raws; + try { + // 1) JSON에서 "text"/"title" 필드의 모든 텍스트 추출 + raws = JsonTextParser.extractAllTexts(entity.getEfficacy()); + } catch (IOException e) { + throw new ScraperException(ScraperErrorCode.PARSING_ERROR); + } + + // 2) 추출된 텍스트 리스트를 단일 문자열로 전처리 + String flatText = SymptomTextParser.flattenLines(raws); + // 3) 전처리된 문자열을 자동완성용 토큰 리스트로 변환 + List suggestTokens = SymptomTextParser.tokenizeForSuggestion(flatText); + + return DrugSymptomDocument.builder() + .drugId(entity.getDrugId()) + .drugName(entity.getDrugName()) + .symptom(flatText) + .symptomSuggester(suggestTokens) + .build(); + } + + /** + * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. + * + * @param symptomDocument 변환 대상 ES Document 객체 + * @return DrugSymptom 도메인 모델 객체 + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { + return DrugSymptom.builder() + .drugId(symptomDocument.getDrugId()) + .drugName(symptomDocument.getDrugName()) + .symptom(symptomDocument.getSymptom()) + .build(); + } + + + /** + * 도메인 모델(DrugSymptom)을 HTTP 응답용 DTO(DrugSymptomResponse)로 변환합니다. + * + * @param drugSymptom 변환 대상 도메인 객체 + * @return DrugSymptomResponse 응답 DTO 객체 + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public static DrugSymptomResponse toResponse(DrugSymptom drugSymptom) { + return DrugSymptomResponse.builder() + .drugId(drugSymptom.getDrugId()) + .drugName(drugSymptom.getDrugName()) + .symptom(drugSymptom.getSymptom()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java new file mode 100644 index 0000000..bf2b0d7 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java @@ -0,0 +1,76 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * JSON 형태의 텍스트 데이터를 파싱하여 + * "text" 또는 "title" 키의 모든 문자열 값을 추출하는 유틸리티 클래스입니다. + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ +public class JsonTextParser { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * JSON 문자열에서 "text" 또는 "title" 필드의 모든 텍스트를 재귀적으로 추출하여 리스트로 반환합니다. + * + * @param json JSON 형식의 문자열 + * @return 추출된 텍스트 값의 리스트 + * @throws IOException JSON 파싱 실패 시 발생 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + public static List extractAllTexts(String json) throws IOException { + // JSON 문자열을 파싱하여 루트 JsonNode 객체를 생성 + JsonNode root = objectMapper.readTree(json); + + // 텍스트 내용을 저장할 리스트 초기화 + List texts = new ArrayList<>(); + + // 루트 노드에서 모든 텍스트를 재귀적으로 수집 + collect(root, texts); + return texts; + } + + /** + * JsonNode를 순회하며 "text"와 "title" 키를 찾아 texts 리스트에 추가합니다. + * + * @param node JSON 노드 + * @param texts 텍스트가 수집될 리스트 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + private static void collect(JsonNode node, List texts) { + // JsonNode의 타입(Object/Array)에 따라 분기 처리 + if (node.isObject()) { + // 객체 노드인 경우, 각 필드(key, value)를 순회 + node.fields().forEachRemaining(e -> { + String key = e.getKey(); + JsonNode val = e.getValue(); + // "text" 또는 "title" 키를 가진 텍스트 필드를 찾았는지 검사 + if ((key.equals("text") || key.equals("title")) && val.isTextual()) { + String t = val.asText().trim(); + if (!t.isEmpty() && !t.equals(" ")) { + texts.add(t); + } + } else { + // 위 조건이 아니면 해당 값을 다시 검사하도록 재귀 호출 + collect(val, texts); + } + }); + } else if (node.isArray()) { + // 배열 노드인 경우, 각 요소를 재귀적으로 순회 + node.forEach(child -> collect(child, texts)); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java new file mode 100644 index 0000000..514e6df --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java @@ -0,0 +1,61 @@ +package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 증상 텍스트 전처리를 위한 유틸리티 클래스입니다. + * - 번호, 헤더, 기호를 제거하여 단일 문자열로 결합하는 기능 + * - 키워드 자동완성을 위한 토큰 생성 기능 + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + +public class SymptomTextParser { + + /** + * 주어진 문자열 목록에서 번호(“1.”), 헤더(“효능효과”), 기호(“○•▶”)를 제거하고 + * 하나의 문자열로 결합합니다. + * + * @param raws 원본 문자열 리스트 (각 줄 단위) + * @return 전처리 후 결합된 단일 문자열 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + public static String flattenLines(List raws) { + // 각 줄에서 번호·헤더·기호를 제거 + return raws.stream() + .map(line -> line.replaceAll("^\\d+\\.\\s*|효능효과|[○•▶]", " ")) + .collect(Collectors.joining(" ")); + } + + /** + * 전처리된 텍스트를 토큰으로 분리하고, + * 불용어 및 조사를 제거하여 자동완성용 키워드 리스트를 생성합니다. + * + * @param text 전처리된 문자열 + * @return 자동완성용 키워드 리스트 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + public static List tokenizeForSuggestion(String text) { + // 구분자(쉼표, 구두점, 공백 등)로 텍스트 분할 + return Arrays.stream(text.split("[,·/;:\\s()\\[\\]]+")) + .map(String::trim) + // 최소 2자 이상인 토큰만 유지 + .filter(tok -> tok.length() >= 2) + // 불용어 필터링 + .filter(tok -> !Set.of("특히", "등의", "또는", "및", "의한").contains(tok)) + // 조사(의, 에, 으로 등) 제거 + .map(tok -> tok.replaceAll("(?.+?)(?:의|에|으로|에서|시의)$", "${base}")) + // 중복 키워드 제거 + .distinct() + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java new file mode 100644 index 0000000..5cc9bb5 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java @@ -0,0 +1,77 @@ +package com.likelion.backendplus4.yakplus.drug.presentation.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.likelion.backendplus4.yakplus.drug.application.service.DrugSymptomService; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomList; +import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomSearchListResponse; +import com.likelion.backendplus4.yakplus.response.ApiResponse; + +import lombok.RequiredArgsConstructor; + +/** + * 약품 증상 데이터 색인 및 자동완성 기능을 제공하는 REST 컨트롤러입니다. + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/symptom") +public class DrugSymptomController { + + private final DrugSymptomService indexService; + + /** + * 색인 배치 작업을 실행하여, DB로부터 조회한 약품 증상 데이터를 Elasticsearch에 일괄 색인합니다. + * + * @return 색인 작업 성공 여부 응답 (Void) + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + @PostMapping("/index") + public ResponseEntity> triggerIndex() { + indexService.indexAll(); + return ApiResponse.success(); + } + + /** + * 사용자 입력 키워드를 바탕으로 증상 자동완성 추천 결과를 조회합니다. + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + @GetMapping("/autocomplete") + public ResponseEntity> autocomplete(@RequestParam String q) { + DrugSymptomSearchListResponse results = indexService.getSymptomAutoComplete(q); + return ApiResponse.success(results); + } + + /** + * 증상 키워드 검색으로 매칭되는 약품명 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 (기본값 0) + * @param size 페이지 당 문서 수 (기본값 20) + * @return 약품명 리스트를 담은 ApiResponse + */ + @GetMapping("/search/names") + public ResponseEntity> searchNames( + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + DrugSymptomList drugSymptomList = indexService.searchDrugNamesBySymptom(q, page, size); + return ApiResponse.success(drugSymptomList); + } + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java new file mode 100644 index 0000000..de2f523 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; + +import java.util.List; + +public record DrugSymptomList( + List symptomResponseList +) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java new file mode 100644 index 0000000..1388d74 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java @@ -0,0 +1,11 @@ +package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; + +import lombok.Builder; + +@Builder +public record DrugSymptomResponse( + Long drugId, + String drugName, + String symptom +) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java new file mode 100644 index 0000000..25a024f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; + +import java.util.List; + +public record DrugSymptomSearchListResponse( + List symptomList +) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java new file mode 100644 index 0000000..e6cbe2c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java @@ -0,0 +1,31 @@ +// package com.likelion.backendplus4.yakplus.temp.api; +// +// 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; +// +// import com.likelion.backendplus4.yakplus.temp.application.EEDocIndexService; +// +// import lombok.RequiredArgsConstructor; +// +// @RestController +// @RequiredArgsConstructor +// @RequestMapping("/api/eedocs") +// public class EEDocIndexController { +// +// private final EEDocIndexService indexService; +// +// @PostMapping("/index") +// public ResponseEntity triggerIndex() { +// try { +// indexService.indexAll(); +// return ResponseEntity.ok("EEDoc indexing completed successfully."); +// } catch (Exception ex) { +// return ResponseEntity +// .status(500) +// .body("Indexing failed: " + ex.getMessage()); +// } +// } +// +// } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java new file mode 100644 index 0000000..95e6cdc --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java @@ -0,0 +1,50 @@ +package com.likelion.backendplus4.yakplus.temp.api; + +import java.io.IOException; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.SearchResponse; + +import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/symptoms") +@RequiredArgsConstructor +public class SymptomController { + + private final ElasticsearchClient esClient; + + @GetMapping("/autocomplete") + public ResponseEntity> autocomplete(@RequestParam String q) throws IOException { + SearchResponse resp = esClient.search(s -> s + .index("eedoc") + .suggest(su -> su + .suggesters("symp_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("symptomSuggester") + .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 + .size(20) + ) + ) + ) + , Void.class); + + // Suggest 파싱 + List results = resp.suggest().get("symp_sugg") + .get(0).completion().options().stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); + + return ResponseEntity.ok(results); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java new file mode 100644 index 0000000..71b3ca8 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java @@ -0,0 +1,97 @@ +// package com.likelion.backendplus4.yakplus.temp.application; +// +// import java.io.IOException; +// import java.util.Arrays; +// import java.util.List; +// import java.util.Objects; +// import java.util.Set; +// import java.util.stream.Collectors; +// +// import org.springframework.stereotype.Service; +// import org.springframework.transaction.annotation.Transactional; +// +// import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrungDetailJpaRepository; +// import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; +// import com.likelion.backendplus4.yakplus.temp.dao.EEDocRepository; +// import com.likelion.backendplus4.yakplus.temp.util.JsonTextExtractor; +// import com.likelion.backendplus4.yakplus.temp.entity.document.EEDocDocument; +// +// import lombok.RequiredArgsConstructor; +// import lombok.extern.slf4j.Slf4j; +// +// @Slf4j +// @Service +// @RequiredArgsConstructor +// public class EEDocIndexService { +// +// private final GovDrungDetailJpaRepository drugRepository; +// private final EEDocRepository docRepository; +// +// @Transactional +// public void indexAll() { +// // TODO 배치 처리 +// List docs = drugRepository.findAll().stream() +// .map(this::toDocument) +// .filter(Objects::nonNull) +// .toList(); +// +// docRepository.saveAll(docs); +// } +// +// /** +// * DB 엔티티 → ES Document 변환 +// */ +// private EEDocDocument toDocument(GovDrugDetailEntity entity) { +// List raws = extractRawLines(entity); +// if (raws == null) +// return null; +// +// String flatText = flattenLines(raws); +// List suggestTokens = tokenizeForSuggestion(flatText); +// +// return EEDocDocument.builder() +// .drugId(entity.getDrugId().toString()) +// .drugName(entity.getDrugName()) +// .symptom(flatText) +// .symptomSuggester(suggestTokens) +// .build(); +// } +// +// /** +// * JSON 파싱 및 텍스트 추출 +// */ +// private List extractRawLines(GovDrugDetailEntity entity) { +// try { +// return JsonTextExtractor.extractAllTexts(entity.getEfficacy()); +// } catch (IOException ex) { +// log.warn("효능효과 JSON 파싱 실패 id={}", entity.getDrugId()); +// return null; +// } +// } +// +// /** +// * 번호·헤더·기호 제거 후 한 줄로 합치기 +// */ +// private String flattenLines(List raws) { +// return raws.stream() +// .map(line -> line.replaceAll("^\\d+\\.\\s*|효능효과|[○•▶]", " ")) +// .collect(Collectors.joining(" ")); +// } +// +// /** +// * 자동완성/제안용 토큰 생성 +// */ +// private List tokenizeForSuggestion(String text) { +// return Arrays.stream(text.split("[,·/;:\\s()\\[\\]]+")) +// .map(String::trim) +// // 길이 2자 이상 +// .filter(tok -> tok.length() >= 2) +// // 불용어 제거 +// .filter(tok -> !Set.of("특히", "등의", "또는", "및", "의한").contains(tok)) +// // 조사 제거 +// .map(tok -> tok.replaceAll("(?.+?)(?:의|에|으로|에서|시의)$", "${base}")) +// // 중복 제거 +// .distinct() +// .toList(); +// } +// } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java new file mode 100644 index 0000000..4effbfd --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.temp.dao; + + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import com.likelion.backendplus4.yakplus.temp.entity.document.EEDocDocument; + +public interface EEDocRepository extends ElasticsearchRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java new file mode 100644 index 0000000..d2f0f8a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java @@ -0,0 +1,36 @@ +package com.likelion.backendplus4.yakplus.temp.entity.document; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.CompletionField; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; + +@Document(indexName = "eedoc") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EEDocDocument { + + @Id + @Field(type = FieldType.Keyword, name = "ITEM_SEQ") + private String drugId; + + @Field(type = FieldType.Text, name = "ITEM_NAME") + private String drugName; + + @Field(type = FieldType.Text, name = "symptom", analyzer = "only_nouns") + private String symptom; + + // ↓ 프로퍼티명을 "symptomSuggester"로 변경해야 ES 매핑과 일치합니다 + @CompletionField(analyzer = "symptom_autocomplete") + private List symptomSuggester; +} From 32603882fe005afabc1924a990aba0345fa81bef Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Fri, 25 Apr 2025 16:36:30 +0900 Subject: [PATCH 15/25] =?UTF-8?q?=E2=9C=A8=20Feature:=20=EB=B2=A1=ED=84=B0?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: 벡터 검색 기능 개발 * ♻️ Refactor: 벡터 검색 기능 리팩토링 및 헥사고날 아키텍처 적용 * ♻️ Refactor: 헥사고날 아키텍쳐에 맞게 일부 객체의 반환값을 변경하였습니다, 컨트롤러에서 parameter가 아닌 객체로 값을 받도록 수정하였습니다. * ♻️ Refactor: 이제 헥사고날 아키텍처에 따라 도메인 객체가 서비스 계층 내부에만 존재합니다, 규칙에 맞추어 Exception처리가 추가되었습니다, 모든 클래스에 주석을 추가하였습니다. * ♻️ Refactor: Index와 Search 기능이 각각 독립적으로 실행될 수 있도록 분리하였습니다. * 🚨 Hotfix: gitignore를 수정하였습니다 --- .../application/port/out/DrugIndexRepositoryPort.java | 9 +++++++++ .../index/application/port/out/EmbeddingPort.java | 5 +++++ .../index/application/port/out/GovDrugRawDataPort.java | 10 ++++++++++ .../application/port/out/DrugSearchRepositoryPort.java | 8 ++++++++ .../search/application/port/out/EmbeddingPort.java | 5 +++++ 5 files changed, 37 insertions(+) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java new file mode 100644 index 0000000..50754d2 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java @@ -0,0 +1,9 @@ +package com.likelion.backendplus4.yakplus.index.application.port.out; + +import com.likelion.backendplus4.yakplus.index.domain.model.Drug; + +import java.util.List; + +public interface DrugIndexRepositoryPort { + void saveAll(List drugs); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java new file mode 100644 index 0000000..5599201 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.index.application.port.out; + +public interface EmbeddingPort { + float[] getEmbedding(String text); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java new file mode 100644 index 0000000..b79185c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java @@ -0,0 +1,10 @@ +package com.likelion.backendplus4.yakplus.index.application.port.out; + +import com.likelion.backendplus4.yakplus.index.domain.model.Drug; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface GovDrugRawDataPort { + List fetchRawData(Long lastSeq, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java new file mode 100644 index 0000000..9ad3c99 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out; + +import java.util.List; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; + +public interface DrugSearchRepositoryPort { + List searchBySymptoms(String query, float[] vector, int from, int size); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java new file mode 100644 index 0000000..a50330c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java @@ -0,0 +1,5 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out; + +public interface EmbeddingPort { + float[] getEmbedding(String text); +} \ No newline at end of file From 4622c3d94d98debfc4ba4024761f3a71182a9908 Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Mon, 28 Apr 2025 14:35:49 +0900 Subject: [PATCH 16/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20=EA=B5=AC=EC=A1=B0=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/in/IndexUseCase.java | 7 -- .../port/out/DrugIndexRepositoryPort.java | 9 -- .../application/port/out/EmbeddingPort.java | 5 - .../port/out/GovDrugRawDataPort.java | 10 -- .../application/service/DrugIndexer.java | 83 ------------- .../index/config/ElasticsearchConfig.java | 8 -- .../yakplus/index/config/OpenAiConfig.java | 18 --- .../yakplus/index/domain/model/Drug.java | 21 ---- .../index/exception/IndexException.java | 18 --- .../index/exception/error/IndexErrorCode.java | 31 ----- .../persistence/ElasticsearchDrugAdapter.java | 112 ----------------- .../persistence/GovDrugRawDataAdapter.java | 115 ------------------ .../persistence/OpenAIEmbeddingAdapter.java | 75 ------------ .../entity/GovDrugRawDataEntity.java | 36 ------ .../repository/RawDataJpaRepository.java | 13 -- .../controller/DrugController.java | 35 ------ .../controller/dto/request/IndexRequest.java | 12 -- .../application/service/DrugSearcher.java | 33 +++-- .../yakplus/search/domain/model/Drug.java | 31 +++-- .../persistence/ElasticsearchDrugAdapter.java | 84 +++++++++---- .../persistence/OpenAIEmbeddingAdapter.java | 16 ++- .../controller/DrugController.java | 3 + .../dto/response/SearchResponse.java | 14 ++- 23 files changed, 125 insertions(+), 664 deletions(-) delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java deleted file mode 100644 index 1045a3e..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/in/IndexUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.application.port.in; - -import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; - -public interface IndexUseCase { - void index(IndexRequest request); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java deleted file mode 100644 index 50754d2..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/DrugIndexRepositoryPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.application.port.out; - -import com.likelion.backendplus4.yakplus.index.domain.model.Drug; - -import java.util.List; - -public interface DrugIndexRepositoryPort { - void saveAll(List drugs); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java deleted file mode 100644 index 5599201..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/EmbeddingPort.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.application.port.out; - -public interface EmbeddingPort { - float[] getEmbedding(String text); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java deleted file mode 100644 index b79185c..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/application/port/out/GovDrugRawDataPort.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.application.port.out; - -import com.likelion.backendplus4.yakplus.index.domain.model.Drug; -import org.springframework.data.domain.Pageable; - -import java.util.List; - -public interface GovDrugRawDataPort { - List fetchRawData(Long lastSeq, Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java b/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java deleted file mode 100644 index 1d4e2fa..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/application/service/DrugIndexer.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.application.service; - -import com.likelion.backendplus4.yakplus.index.application.port.in.IndexUseCase; -import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; -import com.likelion.backendplus4.yakplus.index.application.port.out.GovDrugRawDataPort; -import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; -import com.likelion.backendplus4.yakplus.index.domain.model.Drug; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; - -import java.util.List; - -/** - * 약품 색인(인덱싱) 작업을 수행하는 서비스 구현체 - * - * @since 2025-04-22 - * @modified 2025-04-24 - */ -@Service -@RequiredArgsConstructor -public class DrugIndexer implements IndexUseCase { - private final GovDrugRawDataPort govDrugRawDataPort; - private final DrugIndexRepositoryPort drugIndexRepositoryPort; - private static final String SORT_BY_PROPERTY = "itemSeq"; - - /** - * 요청으로 전달된 lastSeq, limit 정보를 바탕으로 RDB에서 데이터를 조회하고 - * ES 인덱스에 저장한다. - * - * @param request 색인 기준 및 개수 정보 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - @Override - public void index(IndexRequest request) { - Pageable pageable = createPageable(request.limit()); - List drugs = fetchAndTransformRawData(request, pageable); - saveDrugs(drugs); - } - - /** - * RDB에서 lastSeq 이후의 원시 데이터를 조회하여 도메인 객체로 변환한다. - * - * @param request 색인 기준 정보 - * @param pageable 페이징 및 정렬 정보 - * @return 도메인 모델 리스트 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private List fetchAndTransformRawData(IndexRequest request, Pageable pageable) { - return govDrugRawDataPort.fetchRawData(request.lastSeq(), pageable); - } - - /** - * limit 크기 및 itemSeq 오름차순 정렬 기준의 객체를 생성한다. - * - * @param limit 조회할 최대 건수 - * @return 페이징 객체 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private Pageable createPageable(int limit) { - return PageRequest.of(0, limit, Sort.by(SORT_BY_PROPERTY).ascending()); - } - - /** - * 조회된 도메인 객체들을 ES 인덱스에 저장 처리한다. - * - * @param drugs 저장할 도메인 모델 리스트 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private void saveDrugs(List drugs) { - drugIndexRepositoryPort.saveAll(drugs); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java deleted file mode 100644 index 11c5ef0..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/config/ElasticsearchConfig.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.config; - -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ElasticsearchConfig { - // TODO: 필요 시 RestClient 빈 등록 -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java deleted file mode 100644 index 3813256..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/config/OpenAiConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.config; - -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class OpenAiConfig { - - @Value("${spring.ai.openai.api-key}") - private String apiKey; - - @Bean - public OpenAiApi openaiApi() { - return new OpenAiApi(apiKey); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java deleted file mode 100644 index 7b2b03f..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/domain/model/Drug.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.domain.model; - -import lombok.*; - -import java.time.LocalDate; -import java.util.Map; - -@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder -public class Drug { - private Long itemSeq; - private String itemName; - private String entpName; - private String eeText; - private LocalDate itemPermitDate; - private Map materialName; - private String nbDocData; - private String udDocData; - private String storageMethod; - private String validTerm; - private String imgUrl; -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java deleted file mode 100644 index b2b08b9..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/IndexException.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.exception; - -import com.likelion.backendplus4.yakplus.common.exception.CustomException; -import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; - -public class IndexException extends CustomException { - private final ErrorCode errorCode; - - public IndexException(ErrorCode errorCode) { - super(errorCode); - this.errorCode = errorCode; - } - - @Override - public ErrorCode getErrorCode() { - return errorCode; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java deleted file mode 100644 index eba0492..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/exception/error/IndexErrorCode.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.exception.error; - -import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum IndexErrorCode implements ErrorCode { - RAW_DATA_FETCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430001, "원시 데이터 조회 실패"), - ES_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430003, "Elasticsearch 저장 실패"), - EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"); - - private final HttpStatus status; - private final int code; - private final String message; - - @Override - public HttpStatus httpStatus() { - return status; - } - - @Override - public int codeNumber() { - return code; - } - - @Override - public String message() { - return message; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java deleted file mode 100644 index 8156015..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; -import com.likelion.backendplus4.yakplus.index.application.port.out.EmbeddingPort; -import com.likelion.backendplus4.yakplus.index.domain.model.Drug; -import com.likelion.backendplus4.yakplus.index.exception.IndexException; -import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; -import lombok.RequiredArgsConstructor; -import org.apache.http.entity.ContentType; -import org.apache.http.nio.entity.NStringEntity; -import org.elasticsearch.client.Request; -import org.elasticsearch.client.RestClient; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -/** - * Elasticsearch를 통해 Drug 도메인 객체의 색인 기능을 제공하는 어댑터 클래스입니다. - * DrugIndexRepositoryPort를 구현하여 - * Elasticsearch 원격 호출을 캡슐화합니다. - * - * @modified 2025-04-24 - * @since 2025-04-22 - */ -@Component -@RequiredArgsConstructor -public class ElasticsearchDrugAdapter implements DrugIndexRepositoryPort { - private static final String DRUGS_INDEX = "drugs_v2"; - - private final RestClient restClient; - private final ObjectMapper objectMapper; - private final EmbeddingPort embeddingPort; - - /** - * 주어진 Drug 목록을 Elasticsearch에 일괄 저장한다. - * - * @param drugs 저장할 Drug 객체 리스트 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - @Override - public void saveAll(List drugs) { - drugs.forEach(this::saveDrug); - } - - /** - * 단일 Drug 객체를 Elasticsearch에 색인한다. - * 임베딩을 생성한 뒤, 문서 형태로 변환하여 색인 요청을 수행한다. - * 실패 시 SearchException을 발생시킨다. - * - * @param drug 저장할 Drug 도메인 객체 - * @throws IndexException 색인 처리 중 오류 발생 시 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private void saveDrug(Drug drug) { - try { - float[] vector = embeddingPort.getEmbedding(drug.getEeText()); - - Map source = createDrugDocument(drug, vector); - String json = objectMapper.writeValueAsString(source); - - Request request = createIndexRequest(drug.getItemSeq(), json); - restClient.performRequest(request); - } catch (Exception e) { - //TODO: LOG ERROR 처리 요망 -// log(LogLevel.ERROR, "Elasticsearch 저장 실패", e); - throw new IndexException(IndexErrorCode.ES_SAVE_ERROR); - } - } - - /** - * Drug 객체와 임베딩 벡터를 기반으로 Elasticsearch 색인용 문서 필드 맵을 생성한다. - * - * @param drug 색인할 Drug 도메인 객체 - * @param vector 해당 객체에 대한 임베딩 벡터 - * @return Elasticsearch에 저장할 문서 필드 맵 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private Map createDrugDocument(Drug drug, float[] vector) { - return Map.of( - "itemSeq", drug.getItemSeq().toString(), - "itemName", drug.getItemName(), - "entpName", drug.getEntpName(), - "eeText", drug.getEeText(), - "searchAll", drug.getEeText(), - "eeVector", vector - ); - } - - /** - * 지정된 인덱스와 문서 ID로 Elasticsearch 색인 요청 객체를 생성한다. - * - * @param itemSeq 문서 ID로 사용할 itemSeq 값 - * @param json 색인할 JSON 문자열 - * @return 색인 요청을 수행할 Request 객체 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private Request createIndexRequest(Long itemSeq, String json) { - Request request = new Request("POST", "/" + DRUGS_INDEX + "/_doc/" + itemSeq); - request.setEntity(new NStringEntity(json, ContentType.APPLICATION_JSON)); - return request; - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java deleted file mode 100644 index e16b9ad..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/GovDrugRawDataAdapter.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; - -import com.likelion.backendplus4.yakplus.index.application.port.out.GovDrugRawDataPort; -import com.likelion.backendplus4.yakplus.index.exception.IndexException; -import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; -import com.likelion.backendplus4.yakplus.index.infrastructure.entity.GovDrugRawDataEntity; -import com.likelion.backendplus4.yakplus.index.infrastructure.repository.RawDataJpaRepository; -import com.likelion.backendplus4.yakplus.index.domain.model.Drug; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * 공공 API로부터 조회한 원시 약품 데이터를 JPA를 통해 가져와 - * 도메인 객체인 Drug로 변환하는 어댑터 클래스입니다. - * - * @since 2025-04-22 - * @modified 2025-04-24 - */ -@Component -@RequiredArgsConstructor -public class GovDrugRawDataAdapter implements GovDrugRawDataPort { - private final RawDataJpaRepository rawDataJpaRepository; - - /** - * 마지막으로 처리된 시퀀스 이후의 원시 데이터를 페이징 조건에 맞춰 조회하고 - * Drug 도메인 리스트로 변환하여 반환합니다. - * - * @param lastSeq 마지막 처리 시퀀스 (null이면 0부터 조회) - * @param pageable 페이징 및 정렬 정보 - * @return Drug 도메인 객체 리스트 - * @throws IndexException 데이터베이스 조회 실패 시 발생 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - @Override - public List fetchRawData(Long lastSeq, Pageable pageable) { - long startSeq = getStartSeq(lastSeq); - List govDrugRawDataEntities = getGovDrugRawDataEntities(startSeq, pageable); - - return convertToDrugDomains(govDrugRawDataEntities); - } - - /** - * lastSeq가 null일 경우 0으로 치환하여 조회 시작점을 결정합니다. - * - * @param lastSeq 마지막 처리 시퀀스 - * @return 실제 조회 시작 시퀀스 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private Long getStartSeq(Long lastSeq) { - return (lastSeq == null ? 0L : lastSeq); - } - - /** - * JPA 레포지토리를 이용해 itemSeq 기준으로 정렬된 데이터를 조회합니다. - * - * @param lastSeq 마지막으로 조회된 Seq - * @param pageable 페이징 및 정렬 정보 - * @return 조회된 GovDrugRawDataEntity 리스트 - * @throws IndexException 조회 중 예외가 발생하면 SearchErrorCode.RAW_DATA_FETCH_ERROR로 래핑하여 던집니다. - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private List getGovDrugRawDataEntities(Long lastSeq, Pageable pageable) { - try { - return rawDataJpaRepository.findByItemSeqGreaterThanOrderByItemSeqAsc(lastSeq, pageable); - } catch (Exception e) { - //TODO: LOG ERROR 처리 요망 -// log(LogLevel.ERROR, "MySQL 데이터 조회 실패", e); - throw new IndexException(IndexErrorCode.RAW_DATA_FETCH_ERROR); - } - - } - - /** - * 조회된 엔티티 리스트를 Drug 도메인 객체 리스트로 변환합니다. - * - * @param rawData GovDrugRawDataEntity 리스트 - * @return Drug 도메인 객체 리스트 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private List convertToDrugDomains(List rawData) { - return rawData.stream() - .map(this::mapToDrugDomain) - .collect(Collectors.toList()); - } - - /** - * 단일 GovDrugRawDataEntity를 Drug 도메인 객체로 매핑합니다. - * - * @param entity 변환할 GovDrugRawDataEntity - * @return 변환된 Drug 도메인 객체 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private Drug mapToDrugDomain(GovDrugRawDataEntity entity) { - return Drug.builder() - .itemSeq(entity.getItemSeq()) - .itemName(entity.getItemName()) - .entpName(entity.getEntpName()) - .eeText(entity.getEeDocData()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java deleted file mode 100644 index bfae023..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.infrastructure.adapter.persistence; - -import com.likelion.backendplus4.yakplus.index.application.port.out.EmbeddingPort; -import com.likelion.backendplus4.yakplus.index.exception.IndexException; -import com.likelion.backendplus4.yakplus.index.exception.error.IndexErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.embedding.EmbeddingResponse; -import org.springframework.ai.openai.OpenAiEmbeddingModel; -import org.springframework.ai.openai.OpenAiEmbeddingOptions; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.retry.RetryUtils; -import org.springframework.stereotype.Component; - -import java.util.Arrays; -import java.util.List; - -/** - * OpenAI 임베딩 API를 호출하여 텍스트에 대한 벡터 임베딩을 생성하는 어댑터 클래스입니다. - * EmbeddingPort 인터페이스를 구현하며, 오류 발생 시 SearchException으로 래핑합니다. - * - * @since 2025-04-22 - * @modified 2025-04-24 - */ -@Component -@RequiredArgsConstructor -public class OpenAIEmbeddingAdapter implements EmbeddingPort { - private final OpenAiApi openAiApi; - private static final String EMBEDDING_MODEL = "text-embedding-3-small"; - - /** - * 주어진 텍스트를 OpenAI 임베딩 모델에 전달하여 벡터 값을 반환합니다. - * 내부에서 OpenAiEmbeddingModel을 생성하고 retry 템플릿을 적용합니다. - * API 호출 중 예외가 발생하면 SearchException(EMBEDDING_API_ERROR)을 던집니다. - * - * @param text 벡터화할 입력 텍스트 - * @return float 배열 형태의 임베딩 벡터 - * @throws IndexException EMBEDDING_API_ERROR 코드로 래핑하여 발생 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - @Override - public float[] getEmbedding(String text) { - try { - OpenAiEmbeddingModel embeddingModel = createEmbeddingModel(); - EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text)); - return response.getResults().getFirst().getOutput(); - } catch (Exception e) { - //TODO: LOG ERROR 처리 요망 -// log(LOGLEVEL.ERROR, "임베딩 API에서 문제가 발생하였습니다., e); - throw new IndexException(IndexErrorCode.EMBEDDING_API_ERROR); - } - } - - /** - * OpenAiEmbeddingModel 인스턴스를 생성하여 반환합니다. - * MetadataMode와 모델 이름, RetryUtils 설정이 포함됩니다. - * - * @return 초기화된 OpenAiEmbeddingModel 객체 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-24 - */ - private OpenAiEmbeddingModel createEmbeddingModel() { - return new OpenAiEmbeddingModel( - openAiApi, - MetadataMode.EMBED, - OpenAiEmbeddingOptions.builder() - .model(EMBEDDING_MODEL) - .build(), - RetryUtils.DEFAULT_RETRY_TEMPLATE - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java deleted file mode 100644 index a86b91d..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/entity/GovDrugRawDataEntity.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.infrastructure.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.*; - -import java.time.LocalDate; - -@Entity -@Table(name = "gov_drug_detail") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class GovDrugRawDataEntity { - @Id - private Long itemSeq; - private Boolean etcOtcCode; - private LocalDate itemPermitDate; - @Column(columnDefinition = "json") - private String eeDocData; - private String entpName; - private String itemName; - @Column(columnDefinition = "json") - private String materialName; - @Column(columnDefinition = "json") - private String nbDocData; - private String storageMethod; - @Column(columnDefinition = "json") - private String udDocData; - private String validTerm; -// private String imgUrl; -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java deleted file mode 100644 index 3e276fe..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/infrastructure/repository/RawDataJpaRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.infrastructure.repository; - -import com.likelion.backendplus4.yakplus.index.infrastructure.entity.GovDrugRawDataEntity; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface RawDataJpaRepository extends JpaRepository { - List findByItemSeqGreaterThanOrderByItemSeqAsc(Long lastSeq, Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java deleted file mode 100644 index 929b26e..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/DrugController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.presentation.controller; - -import com.likelion.backendplus4.yakplus.index.application.port.in.IndexUseCase; -import com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request.IndexRequest; -import lombok.RequiredArgsConstructor; -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; - -/** - * 약품 인덱싱 API 엔드포인트를 제공하는 컨트롤러 클래스 - * - * @modified 2025-04-25 - * @since 2025-04-22 - */ -@RestController -@RequestMapping("/api/drugs/index") -@RequiredArgsConstructor -public class DrugController { - private final IndexUseCase indexUseCase; - - /** - * 색인 생성 요청을 처리한다. - * - * @param request 인덱싱 범위 및 개수 정보를 담은 요청 객체 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - @PostMapping("/index") - public void index(@RequestBody IndexRequest request) { - indexUseCase.index(request); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java b/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java deleted file mode 100644 index e096a37..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/index/presentation/controller/dto/request/IndexRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.likelion.backendplus4.yakplus.index.presentation.controller.dto.request; - -/** - * 인덱싱 요청 정보 DTO - * - * @since 2025-04-22 - * @modified 2025-04-24 - */ -public record IndexRequest( - Long lastSeq, - int limit) { -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index f7ea534..b79fb7a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -1,5 +1,6 @@ package com.likelion.backendplus4.yakplus.search.application.service; +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; @@ -14,12 +15,14 @@ import java.util.List; import java.util.stream.Collectors; +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + /** * 사용자 검색 요청을 처리하고, 벡터 유사도 및 텍스트 검색을 통해 * 결과를 반환하는 서비스 구현체 * - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ @Service @RequiredArgsConstructor @@ -35,11 +38,12 @@ public class DrugSearcher implements SearchDrugUseCase { * @return 검색 결과 DTO 리스트 * @throws SearchException 검색어가 유효하지 않은 경우 발생 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ @Override public List search(SearchRequest request) { + log("search() 메서드 호출, 검색어: " + request.query()); validateQuery(request.query()); float[] embeddings = generateEmbeddings(request.query()); return searchDrugs(request, embeddings); @@ -52,13 +56,13 @@ public List search(SearchRequest request) { * @param query 검색어 문자열 * @throws SearchException INVALID_QUERY 코드와 함께 발생 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ private void validateQuery(String query) { + log("validateQuery() 메서드 호출, 검색어: " + query); if (query == null || query.isEmpty()) { - //TODO: LOG WARN 처리 요망 -// log(LogLevel.warn, "검색 쿼리가 비어있거나 null입니다."); + log(LogLevel.ERROR, "검색 쿼리가 비어있거나 null입니다."); throw new SearchException(SearchErrorCode.INVALID_QUERY); } } @@ -69,10 +73,11 @@ private void validateQuery(String query) { * @param query 검색어 문자열 * @return 임베딩 벡터 배열 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ private float[] generateEmbeddings(String query) { + log("generateEmbeddings() 메서드 호출, 검색어: " + query); return embeddingPort.getEmbedding(query); } @@ -84,11 +89,13 @@ private float[] generateEmbeddings(String query) { * @param embeddings 검색어에 대한 임베딩 벡터 배열 * @return SearchResponse 객체 리스트 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ private List searchDrugs(SearchRequest searchRequest, float[] embeddings) { + log("searchDrugs() 메서드 호출, 검색어: " + searchRequest.query()); List drugs = drugSearchRepositoryPort.searchBySymptoms(searchRequest.query(), embeddings, searchRequest.size(), searchRequest.page() * searchRequest.size()); + log("searchDrugs() 메서드 완료, 검색어: " + searchRequest.query() + ", 검색 결과 개수: " + drugs.size()); return mapToDrugDomain(drugs); } @@ -100,15 +107,17 @@ private List searchDrugs(SearchRequest searchRequest, float[] em * @return SearchResponse DTO 리스트 * @author 정안식 * @since 2025-04-22 - * @modified 2025-04-24 + * @modified 2025-04-27 + * 25.04.27 - Drug 도메인 객체의 필드명에 맞추어 수정 */ private List mapToDrugDomain(List drugs) { return drugs.stream() .map(d -> new SearchResponse( - d.getItemSeq(), - d.getItemName(), - d.getEntpName(), - d.getEeText() + d.getDrugId(), + d.getDrugName(), + d.getCompany(), + d.getEfficacy(), + d.getImageUrl() )) .collect(Collectors.toList()); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java index 1b65433..661c21a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java @@ -1,20 +1,29 @@ package com.likelion.backendplus4.yakplus.search.domain.model; +import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; import lombok.*; + import java.time.LocalDate; +import java.util.List; import java.util.Map; -@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class Drug { - private Long itemSeq; - private String itemName; - private String entpName; - private String eeText; - private LocalDate itemPermitDate; - private Map materialName; - private String nbDocData; - private String udDocData; - private String storageMethod; + private Long drugId; + private String drugName; + private String company; + private List efficacy; + private float[] vector; + private LocalDate permitDate; + private boolean isGeneral; + private List materialInfo; + private String storeMethod; private String validTerm; - private String imgUrl; + private List usage; + private Map> precaution; + private String imageUrl; } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index 397e089..8ce60d6 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -2,9 +2,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.index.application.port.out.DrugIndexRepositoryPort; +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; -import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; @@ -20,15 +19,16 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; -import java.util.Map; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; /** * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. - * DrugSearchRepositoryPort를 구현하여 - * Elasticsearch 원격 호출을 캡슐화합니다. + * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. * + * @modified 2025-04-27 + * 25.04.27 - searchBySymptoms() 메서드 리팩토링 * @since 2025-04-22 - * @modified 2025-04-24 */ @Component @RequiredArgsConstructor @@ -49,20 +49,20 @@ public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { * @return 검색된 Drug 도메인 객체 리스트 * @throws SearchException 검색 처리 중 오류 발생 시 * @author 정안식 + * @modified 2025-04-27 + * 25.04.27 - 코드 리팩토링 * @since 2025-04-22 - * @modified 2025-04-24 */ @Override public List searchBySymptoms(String query, float[] vector, int size, int from) { try { + log("searchBySymptoms() 메서드 호출, 검색어: " + query); String esQuery = buildSearchQuery(query, vector, size, from); Response response = executeSearch(esQuery); - List results = parseSearchResults(response); - return results; + return parseSearchResults(response); } catch (Exception e) { - //TODO: LOG ERROR 처리 요망 -// log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + query, e); + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + query, e); throw new SearchException(SearchErrorCode.ES_SEARCH_ERROR); } } @@ -78,11 +78,14 @@ public List searchBySymptoms(String query, float[] vector, int size, int f * @return Elasticsearch에 전달할 쿼리 JSON 문자열 * @throws IOException JSON 직렬화 과정에서 오류 발생 시 * @author 정안식 + * @modified 2025-04-27 + * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 + * - 텍스트 매칭 필드 searchAll → efficacy 로 변경(현재는 약의 효과로만 검색하고 있음) * @since 2025-04-22 - * @modified 2025-04-24 */ private String buildSearchQuery(String query, float[] vector, int size, int from) throws IOException { String vectorJson = objectMapper.writeValueAsString(vector); + String escapedQuery = query.replace("\"", "\\\\\""); return """ { "from": %d, @@ -93,7 +96,7 @@ private String buildSearchQuery(String query, float[] vector, int size, int from "script_score": { "query": { "match_all": {} }, "script": { - "inline": "cosineSimilarity(params.queryVector, 'eeVector') + 1.0", + "inline": "cosineSimilarity(params.queryVector, 'vector') + 1.0", "lang": "painless", "params": { "queryVector": %s } } @@ -102,7 +105,7 @@ private String buildSearchQuery(String query, float[] vector, int size, int from "should": [ { "match": { - "searchAll": { + "efficacy": { "query": "%s", "fuzziness": "AUTO", "boost": 0.2 @@ -113,7 +116,7 @@ private String buildSearchQuery(String query, float[] vector, int size, int from } } } - """.formatted(from, size, vectorJson, query.replace("\"", "\\\\\"")); + """.formatted(from, size, vectorJson, escapedQuery); } /** @@ -123,10 +126,11 @@ private String buildSearchQuery(String query, float[] vector, int size, int from * @return Elasticsearch 응답 객체 * @throws IOException 요청 전송 또는 응답 처리 중 오류 발생 시 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ private Response executeSearch(String esQuery) throws IOException { + log("executeSearch() 메서드 호출"); Request request = new Request("GET", "/" + SEARCH_INDEX + "/_search"); request.setEntity(new NStringEntity(esQuery, ContentType.APPLICATION_JSON)); return restClient.performRequest(request); @@ -140,24 +144,60 @@ private Response executeSearch(String esQuery) throws IOException { * @return 파싱된 Drug 도메인 객체 리스트 * @throws IOException 응답 스트림 처리 중 오류 발생 시 * @author 정안식 + * @modified 2025-04-27 + * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 * @since 2025-04-22 - * @modified 2025-04-24 */ private List parseSearchResults(Response response) throws IOException { + log("parseSearchResults() 메서드 호출"); InputStream is = response.getEntity().getContent(); JsonNode hits = objectMapper.readTree(is).path("hits").path("hits"); List results = new ArrayList<>(); for (JsonNode hit : hits) { - JsonNode source = hit.path("_source"); + JsonNode src = hit.path("_source"); + + long drugId = src.path("drugId").asLong(); + String drugName = src.path("drugName").asText(); + String company = src.path("company").asText(); + + List efficacyList = new ArrayList<>(); + for (JsonNode e : src.path("efficacy")) { + efficacyList.add(e.asText()); + } + + String imageUrl = src.path("imageUrl").asText(null); + float[] vectorArr = parseVector(src.path("vector")); + Drug drug = Drug.builder() - .itemSeq(source.path("itemSeq").asLong()) - .itemName(source.path("itemName").asText()) - .entpName(source.path("entpName").asText()) - .eeText(source.path("eeText").asText()) + .drugId(drugId) + .drugName(drugName) + .company(company) + .efficacy(efficacyList) + .imageUrl(imageUrl) + .vector(vectorArr) .build(); results.add(drug); } return results; } + + /** + * JSON 배열로 전달된 vector 노드를 float[]로 변환한다. + * + * @param vectorNode vectors JSON 배열 노드 + * @return float[] 변환된 벡터 배열 + * @author 정안식 + * @since 2025-04-27 + */ + private float[] parseVector(JsonNode vectorNode) { + if (!vectorNode.isArray()) { + return new float[0]; + } + float[] vec = new float[vectorNode.size()]; + for (int i = 0; i < vectorNode.size(); i++) { + vec[i] = vectorNode.get(i).floatValue(); + } + return vec; + } } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java index 298fa3b..a287d5d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java @@ -1,9 +1,11 @@ package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.MetadataMode; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.openai.OpenAiEmbeddingModel; @@ -12,16 +14,18 @@ import org.springframework.ai.retry.RetryUtils; import org.springframework.stereotype.Component; -import java.util.Arrays; import java.util.List; +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + /** * OpenAI 임베딩 API를 호출하여 텍스트에 대한 벡터 임베딩을 생성하는 어댑터 클래스입니다. * EmbeddingPort 인터페이스를 구현하며, 오류 발생 시 SearchException으로 래핑합니다. * - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ +@Slf4j @Component @RequiredArgsConstructor public class OpenAIEmbeddingAdapter implements EmbeddingPort { @@ -37,8 +41,8 @@ public class OpenAIEmbeddingAdapter implements EmbeddingPort { * @return float 배열 형태의 임베딩 벡터 * @throws SearchException EMBEDDING_API_ERROR 코드로 래핑하여 발생 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ @Override public float[] getEmbedding(String text) { @@ -47,8 +51,7 @@ public float[] getEmbedding(String text) { EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text)); return response.getResults().getFirst().getOutput(); } catch (Exception e) { - //TODO: LOG ERROR 처리 요망 -// log(LOGLEVEL.ERROR, "임베딩 API에서 문제가 발생하였습니다., e); + log(LogLevel.ERROR, "임베딩 API에서 문제가 발생하였습니다.", e); throw new SearchException(SearchErrorCode.EMBEDDING_API_ERROR); } } @@ -59,10 +62,11 @@ public float[] getEmbedding(String text) { * * @return 초기화된 OpenAiEmbeddingModel 객체 * @author 정안식 - * @since 2025-04-22 * @modified 2025-04-24 + * @since 2025-04-22 */ private OpenAiEmbeddingModel createEmbeddingModel() { + log("createEmbeddingModel() 메서드 호출, 임베딩 모델: " + EMBEDDING_MODEL); return new OpenAiEmbeddingModel( openAiApi, MetadataMode.EMBED, diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 40090bb..87a51aa 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -13,6 +13,8 @@ import java.util.List; +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + /** * 약품 검색 API 엔드포인트를 제공하는 컨트롤러 클래스 * @@ -36,6 +38,7 @@ public class DrugController { */ @PostMapping("/search") public ResponseEntity>> search(@RequestBody SearchRequest searchRequest) { + log("drugController 요청 수신" + searchRequest.toString()); return ApiResponse.success(searchDrugUseCase.search(searchRequest)); } } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java index e8b346f..5910e87 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java @@ -1,14 +1,18 @@ package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; +import java.util.List; + /** * 검색 결과 정보 DTO * + * @modified 2025-04-27 + * 25.04.27 - Drug 도메인 객체 변경에 따른 필드 수정 * @since 2025-04-22 - * @modified 2025-04-24 */ public record SearchResponse( - Long itemSeq, - String itemName, - String entpName, - String eeText) { + Long drugId, + String drugName, + String company, + List efficacy, + String imageUrl) { } From c753678d9c346bed16f6cd30af343064eadebbb8 Mon Sep 17 00:00:00 2001 From: chanbyeong <122460524+chanbyoung@users.noreply.github.com> Date: Mon, 28 Apr 2025 14:48:27 +0900 Subject: [PATCH 17/25] =?UTF-8?q?=E2=9C=A8=20Feature/#52=20=EC=A6=9D?= =?UTF-8?q?=EC=83=81=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#5?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ Refactor: Index 폴더 및 temp파일 삭제 * ♻️ Refactor: Drug 폴더 temp파일 삭제 * ✨ Feat: 응답 객체 및 에러 코드 정의 * ✨ Feat: 증상 자동완성 API 구현 * ✨ Feat: 증상 검색 기능 구현 * ♻️ Refactor: 변경된 구조에 따라 코드를 수정하였습니다. * ♻️ Refactor: Index 폴더 삭제 * ♻️ Refactor: 응답 객체 통일화 및 Document 필드 변화에 따른 수정 * ✨ Feat: 누락된 로그 추가 * ♻️ Refactor: 안쓰는 파일 삭제 --------- Co-authored-by: leelise --- .../service/DrugSymptomService.java | 97 ------------- .../drug/exception/EsSuggestException.java | 17 --- .../drug/exception/error/EsErrorCode.java | 32 ----- .../adapter/DrugSymptomEsAdapter.java | 128 ------------------ .../elasticsearch/DrugSymptomRepository.java | 8 -- .../support/mapper/SymptomMapper.java | 91 ------------- .../support/parser/JsonTextParser.java | 76 ----------- .../support/parser/SymptomTextParser.java | 61 --------- .../controller/DrugSymptomController.java | 77 ----------- .../controller/dto/DrugSymptomList.java | 8 -- .../controller/dto/DrugSymptomResponse.java | 11 -- .../dto/DrugSymptomSearchListResponse.java | 8 -- .../port/in/SearchDrugUseCase.java | 7 + .../port/out/DrugSearchRepositoryPort.java | 5 + .../application/service/DrugSearcher.java | 42 +++++- .../exception/error/SearchErrorCode.java | 4 +- .../domain/model/DrugSymptom.java | 8 +- .../persistence/ElasticsearchDrugAdapter.java | 90 ++++++++++++ .../document/DrugSymptomDocument.java | 19 ++- .../infrastructure/support/SymptomMapper.java | 55 ++++++++ .../controller/DrugController.java | 46 ++++++- .../dto/response/AutoCompleteStringList.java | 13 ++ .../dto/response/SearchResponse.java | 4 + .../dto/response/SearchResponseList.java | 13 ++ .../temp/api/EEDocIndexController.java | 31 ----- .../yakplus/temp/api/SymptomController.java | 50 ------- .../temp/application/EEDocIndexService.java | 97 ------------- .../yakplus/temp/dao/EEDocRepository.java | 9 -- .../temp/entity/document/EEDocDocument.java | 36 ----- src/main/resources/application.yml | 2 +- 30 files changed, 299 insertions(+), 846 deletions(-) delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java rename src/main/java/com/likelion/backendplus4/yakplus/{drug => search}/domain/model/DrugSymptom.java (58%) rename src/main/java/com/likelion/backendplus4/yakplus/{drug/infrastructure/adapter/persistence/repository => search/infrastructure/adapter/persistence}/document/DrugSymptomDocument.java (60%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/AutoCompleteStringList.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java deleted file mode 100644 index 140fd98..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugSymptomService.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -import java.util.List; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; - -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.DrugSymptomEsAdapter; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.GovDrugJpaAdapter; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomList; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomResponse; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomSearchListResponse; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 약품 증상 데이터를 처리하는 서비스입니다. - * @sice 2025-04-24 - * @modified 2025-04-25 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class DrugSymptomService { - - private static final int CHUNK_SIZE = 1_000; - - private final GovDrugJpaAdapter drugJpaAdapter; - private final DrugSymptomEsAdapter symptomAdapter; - - /** - * DB에서 약품 데이터를 페이징으로 가져와 Elasticsearch에 일괄 색인합니다. - * 각 페이지는 CHUNK_SIZE만큼 처리되며, 모든 데이터를 순차적으로 색인합니다. - * - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - public void indexAll() { - int page = 0; - Page drugPage; - - do { - // 1. 페이징으로 DB에서 한 청크 가져오기 - drugPage = drugJpaAdapter.findAllDrugs(PageRequest.of(page, CHUNK_SIZE)); - - // 2. 도메인 → ES Document 변환 - List docs = drugPage.stream() - .map(SymptomMapper::toDocument) // 내부에서 예외 처리 됨 - .toList(); - - // 3. 청크별 ES에 색인 - symptomAdapter.saveAll(docs); - - // 4. 다음 1000개 값 루프 - page++; - } while (drugPage.hasNext()); - } - - /** - * 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다. - * Elasticsearch에서 Suggest API 등을 활용하여 추천 결과를 반환합니다. - * - * @param q 사용자 입력 문자열 - * @return 자동완성 추천 결과 리스트 DTO - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - public DrugSymptomSearchListResponse getSymptomAutoComplete(String q) { - return new DrugSymptomSearchListResponse(symptomAdapter.getSearchAutoCompleteResponse(q)); - } - - /** - * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. - * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 - * @param size 페이지 당 문서 수 - * @return 중복 제거된 약품명 리스트 - * @since 2025-04-25 - * @modified 2025-04-25 - */ - public DrugSymptomList searchDrugNamesBySymptom(String q, int page, int size) { - List drugSymptomResponses = symptomAdapter.searchDocsBySymptom(q, page, size) - .stream() - .map(SymptomMapper::toResponse) - .toList(); - - return new DrugSymptomList(drugSymptomResponses); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java deleted file mode 100644 index fbf5156..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/EsSuggestException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.exception; - -import com.likelion.backendplus4.yakplus.common.exception.CustomException;import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; - -public class EsSuggestException extends CustomException { - private final ErrorCode errorCode; - - public EsSuggestException(ErrorCode errorCode) { - super(errorCode); - this.errorCode = errorCode; - } - - @Override - public ErrorCode getErrorCode() { - return errorCode; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java deleted file mode 100644 index 74e9a28..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/EsErrorCode.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.exception.error; - -import org.springframework.http.HttpStatus; - -import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public enum EsErrorCode implements ErrorCode { - ES_SUGGEST_SEARCH_FAIL(440001, HttpStatus.INTERNAL_SERVER_ERROR, "검색어 자동완성에 실패했습니다."), - ES_SEARCH_FAIL(440002, HttpStatus.INTERNAL_SERVER_ERROR, "증상 검색에 실패했습니다."); - - private final int codeNumber; - private final HttpStatus httpStatus; - private final String message; - - @Override - public HttpStatus httpStatus() { - return httpStatus; - } - - @Override - public int codeNumber() { - return codeNumber; - } - - @Override - public String message() { - return message; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java deleted file mode 100644 index a7f9535..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/DrugSymptomEsAdapter.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter; - - -import static org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName.*; - -import java.io.IOException; -import java.util.List; -import java.util.Objects; - -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import com.likelion.backendplus4.yakplus.drug.domain.model.DrugSymptom; -import com.likelion.backendplus4.yakplus.drug.exception.EsSuggestException; -import com.likelion.backendplus4.yakplus.drug.exception.error.EsErrorCode; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.elasticsearch.DrugSymptomRepository; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper; - -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch.core.SearchResponse; -import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; -import co.elastic.clients.elasticsearch.core.search.Hit; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Elasticsearch를 통해 약품 증상 문서를 색인하고, - * 자동완성 제안 결과를 제공하는 어댑터입니다. - * - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class DrugSymptomEsAdapter { - - private final DrugSymptomRepository symptomRepository; - private final ElasticsearchClient esClient; - - /** - * 주어진 증상 문서 리스트를 Elasticsearch에 색인합니다. - * - * @param docs 색인할 DrugSymptomDocument 객체 리스트 - * @author 박찬병 - * @modified 2025-04-25 - * @since 2025-04-24 - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void saveAll(List docs) { - symptomRepository.saveAll(docs); - } - - /** - * 사용자 입력 키워드를 바탕으로 Elasticsearch Suggest API를 호출해 - * 자동완성 추천 단어 리스트를 반환합니다. - * - * @param q 사용자 입력 문자열 - * @return 추천 키워드 리스트 - * @throws EsSuggestException 자동완성 API 호출 실패 시 발생 - * @author 박찬병 - * @modified 2025-04-25 - * @since 2025-04-24 - */ - public List getSearchAutoCompleteResponse(String q) { - SearchResponse resp; - try { - resp = esClient.search(s -> s - .index("eedoc") - .suggest(su -> su - .suggesters("symp_sugg", sg -> sg - .prefix(q) - .completion(c -> c - .field("symptomSuggester") - .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 - .size(20) - ) - ) - ) - , Void.class); - } catch (IOException e) { - throw new EsSuggestException(EsErrorCode.ES_SUGGEST_SEARCH_FAIL); - } - - // Suggest 파싱 - return resp.suggest().get("symp_sugg") - .get(0).completion().options().stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); - } - - /** - * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. - * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 (0부터 시작) - * @param size 페이지 당 문서 수 - * @return 증상 문서 리스트 - * @throws EsSuggestException 검색 중 오류 발생 시 - */ - public List searchDocsBySymptom(String q, int page, int size) { - try { - SearchResponse resp = esClient.search(s -> s - .index(INDEX) - .from(page * size) - .size(size) - .query(qb -> qb - .multiMatch(mm -> mm - .fields("symptom") - .query(q) - .fuzziness("AUTO") - ) - ), DrugSymptomDocument.class); - return resp.hits().hits().stream() - .map(Hit::source) - .filter(Objects::nonNull) - .map(SymptomMapper::toDomain) - .toList(); - } catch (IOException e) { - log.error("ES 증상 문서 검색 실패: q={}", q, e); - throw new EsSuggestException(EsErrorCode.ES_SEARCH_FAIL); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java deleted file mode 100644 index bf3e230..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/elasticsearch/DrugSymptomRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.elasticsearch; - -import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; - -public interface DrugSymptomRepository extends ElasticsearchRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java deleted file mode 100644 index 311139a..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/SymptomMapper.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper; - -import java.io.IOException; -import java.util.List; - -import com.likelion.backendplus4.yakplus.drug.domain.model.DrugSymptom; -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import com.likelion.backendplus4.yakplus.drug.exception.ScraperException; -import com.likelion.backendplus4.yakplus.drug.exception.error.ScraperErrorCode; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.JsonTextParser; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.SymptomTextParser; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomResponse; - -/** - * 증상 관련 Document를 다루는 매퍼 클래스입니다. - * - * @author 박찬병 - * @since 2025-04-25 - * @modified 2025-04-25 - */ -public class SymptomMapper { - - /** - * 주어진 GovDrug 도메인 객체를 기반으로 ES 색인용 DrugSymptomDocument로 변환합니다. - * 내부에서 JSON 파싱 및 전처리 로직을 실행하며, 파싱 실패 시 ScraperException을 던집니다. - * - * @param entity 변환 대상 GovDrug 도메인 객체 - * @return 변환된 DrugSymptomDocument 객체 - * @throws ScraperException JSON 파싱 실패 시 발생 - * @author 박찬병 - * @since 2025-04-25 - * @modified 2025-04-25 - */ - public static DrugSymptomDocument toDocument(GovDrug entity) { - List raws; - try { - // 1) JSON에서 "text"/"title" 필드의 모든 텍스트 추출 - raws = JsonTextParser.extractAllTexts(entity.getEfficacy()); - } catch (IOException e) { - throw new ScraperException(ScraperErrorCode.PARSING_ERROR); - } - - // 2) 추출된 텍스트 리스트를 단일 문자열로 전처리 - String flatText = SymptomTextParser.flattenLines(raws); - // 3) 전처리된 문자열을 자동완성용 토큰 리스트로 변환 - List suggestTokens = SymptomTextParser.tokenizeForSuggestion(flatText); - - return DrugSymptomDocument.builder() - .drugId(entity.getDrugId()) - .drugName(entity.getDrugName()) - .symptom(flatText) - .symptomSuggester(suggestTokens) - .build(); - } - - /** - * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. - * - * @param symptomDocument 변환 대상 ES Document 객체 - * @return DrugSymptom 도메인 모델 객체 - * @author 박찬병 - * @since 2025-04-25 - * @modified 2025-04-25 - */ - public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { - return DrugSymptom.builder() - .drugId(symptomDocument.getDrugId()) - .drugName(symptomDocument.getDrugName()) - .symptom(symptomDocument.getSymptom()) - .build(); - } - - - /** - * 도메인 모델(DrugSymptom)을 HTTP 응답용 DTO(DrugSymptomResponse)로 변환합니다. - * - * @param drugSymptom 변환 대상 도메인 객체 - * @return DrugSymptomResponse 응답 DTO 객체 - * @author 박찬병 - * @since 2025-04-25 - * @modified 2025-04-25 - */ - public static DrugSymptomResponse toResponse(DrugSymptom drugSymptom) { - return DrugSymptomResponse.builder() - .drugId(drugSymptom.getDrugId()) - .drugName(drugSymptom.getDrugName()) - .symptom(drugSymptom.getSymptom()) - .build(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java deleted file mode 100644 index bf2b0d7..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/JsonTextParser.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -/** - * JSON 형태의 텍스트 데이터를 파싱하여 - * "text" 또는 "title" 키의 모든 문자열 값을 추출하는 유틸리티 클래스입니다. - * - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ -public class JsonTextParser { - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - /** - * JSON 문자열에서 "text" 또는 "title" 필드의 모든 텍스트를 재귀적으로 추출하여 리스트로 반환합니다. - * - * @param json JSON 형식의 문자열 - * @return 추출된 텍스트 값의 리스트 - * @throws IOException JSON 파싱 실패 시 발생 - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - public static List extractAllTexts(String json) throws IOException { - // JSON 문자열을 파싱하여 루트 JsonNode 객체를 생성 - JsonNode root = objectMapper.readTree(json); - - // 텍스트 내용을 저장할 리스트 초기화 - List texts = new ArrayList<>(); - - // 루트 노드에서 모든 텍스트를 재귀적으로 수집 - collect(root, texts); - return texts; - } - - /** - * JsonNode를 순회하며 "text"와 "title" 키를 찾아 texts 리스트에 추가합니다. - * - * @param node JSON 노드 - * @param texts 텍스트가 수집될 리스트 - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - private static void collect(JsonNode node, List texts) { - // JsonNode의 타입(Object/Array)에 따라 분기 처리 - if (node.isObject()) { - // 객체 노드인 경우, 각 필드(key, value)를 순회 - node.fields().forEachRemaining(e -> { - String key = e.getKey(); - JsonNode val = e.getValue(); - // "text" 또는 "title" 키를 가진 텍스트 필드를 찾았는지 검사 - if ((key.equals("text") || key.equals("title")) && val.isTextual()) { - String t = val.asText().trim(); - if (!t.isEmpty() && !t.equals(" ")) { - texts.add(t); - } - } else { - // 위 조건이 아니면 해당 값을 다시 검사하도록 재귀 호출 - collect(val, texts); - } - }); - } else if (node.isArray()) { - // 배열 노드인 경우, 각 요소를 재귀적으로 순회 - node.forEach(child -> collect(child, texts)); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java deleted file mode 100644 index 514e6df..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/SymptomTextParser.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; - -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * 증상 텍스트 전처리를 위한 유틸리티 클래스입니다. - * - 번호, 헤더, 기호를 제거하여 단일 문자열로 결합하는 기능 - * - 키워드 자동완성을 위한 토큰 생성 기능 - * - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - -public class SymptomTextParser { - - /** - * 주어진 문자열 목록에서 번호(“1.”), 헤더(“효능효과”), 기호(“○•▶”)를 제거하고 - * 하나의 문자열로 결합합니다. - * - * @param raws 원본 문자열 리스트 (각 줄 단위) - * @return 전처리 후 결합된 단일 문자열 - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - public static String flattenLines(List raws) { - // 각 줄에서 번호·헤더·기호를 제거 - return raws.stream() - .map(line -> line.replaceAll("^\\d+\\.\\s*|효능효과|[○•▶]", " ")) - .collect(Collectors.joining(" ")); - } - - /** - * 전처리된 텍스트를 토큰으로 분리하고, - * 불용어 및 조사를 제거하여 자동완성용 키워드 리스트를 생성합니다. - * - * @param text 전처리된 문자열 - * @return 자동완성용 키워드 리스트 - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - public static List tokenizeForSuggestion(String text) { - // 구분자(쉼표, 구두점, 공백 등)로 텍스트 분할 - return Arrays.stream(text.split("[,·/;:\\s()\\[\\]]+")) - .map(String::trim) - // 최소 2자 이상인 토큰만 유지 - .filter(tok -> tok.length() >= 2) - // 불용어 필터링 - .filter(tok -> !Set.of("특히", "등의", "또는", "및", "의한").contains(tok)) - // 조사(의, 에, 으로 등) 제거 - .map(tok -> tok.replaceAll("(?.+?)(?:의|에|으로|에서|시의)$", "${base}")) - // 중복 키워드 제거 - .distinct() - .toList(); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java deleted file mode 100644 index 5cc9bb5..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugSymptomController.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.likelion.backendplus4.yakplus.drug.application.service.DrugSymptomService; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomList; -import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomSearchListResponse; -import com.likelion.backendplus4.yakplus.response.ApiResponse; - -import lombok.RequiredArgsConstructor; - -/** - * 약품 증상 데이터 색인 및 자동완성 기능을 제공하는 REST 컨트롤러입니다. - * - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/symptom") -public class DrugSymptomController { - - private final DrugSymptomService indexService; - - /** - * 색인 배치 작업을 실행하여, DB로부터 조회한 약품 증상 데이터를 Elasticsearch에 일괄 색인합니다. - * - * @return 색인 작업 성공 여부 응답 (Void) - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - @PostMapping("/index") - public ResponseEntity> triggerIndex() { - indexService.indexAll(); - return ApiResponse.success(); - } - - /** - * 사용자 입력 키워드를 바탕으로 증상 자동완성 추천 결과를 조회합니다. - * - * @param q 검색어 프리픽스 - * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - */ - @GetMapping("/autocomplete") - public ResponseEntity> autocomplete(@RequestParam String q) { - DrugSymptomSearchListResponse results = indexService.getSymptomAutoComplete(q); - return ApiResponse.success(results); - } - - /** - * 증상 키워드 검색으로 매칭되는 약품명 리스트를 반환합니다. - * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 (기본값 0) - * @param size 페이지 당 문서 수 (기본값 20) - * @return 약품명 리스트를 담은 ApiResponse - */ - @GetMapping("/search/names") - public ResponseEntity> searchNames( - @RequestParam String q, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - DrugSymptomList drugSymptomList = indexService.searchDrugNamesBySymptom(q, page, size); - return ApiResponse.success(drugSymptomList); - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java deleted file mode 100644 index de2f523..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomList.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; - -import java.util.List; - -public record DrugSymptomList( - List symptomResponseList -) { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java deleted file mode 100644 index 1388d74..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; - -import lombok.Builder; - -@Builder -public record DrugSymptomResponse( - Long drugId, - String drugName, - String symptom -) { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java deleted file mode 100644 index 25a024f..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/dto/DrugSymptomSearchListResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller.dto; - -import java.util.List; - -public record DrugSymptomSearchListResponse( - List symptomList -) { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java index 052df95..926a0d2 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -1,10 +1,17 @@ package com.likelion.backendplus4.yakplus.search.application.port.in; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; import java.util.List; public interface SearchDrugUseCase { List search(SearchRequest searchRequest); + + AutoCompleteStringList getSymptomAutoComplete(String q); + + SearchResponseList searchDrugNamesBySymptom(String q, int page, int size); + } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java index 9ad3c99..65a7ab3 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java @@ -2,7 +2,12 @@ import java.util.List; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; public interface DrugSearchRepositoryPort { List searchBySymptoms(String query, float[] vector, int from, int size); + + List getSymptomAutoCompleteResponse(String q); + + List searchDocsBySymptom(String q, int page, int size); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index b79fb7a..1cc5dac 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -1,6 +1,8 @@ package com.likelion.backendplus4.yakplus.search.application.service; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.SymptomMapper; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; @@ -9,6 +11,8 @@ import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,7 +25,7 @@ * 사용자 검색 요청을 처리하고, 벡터 유사도 및 텍스트 검색을 통해 * 결과를 반환하는 서비스 구현체 * - * @modified 2025-04-24 + * @modified 2025-04-28 * @since 2025-04-22 */ @Service @@ -49,6 +53,42 @@ public List search(SearchRequest request) { return searchDrugs(request, embeddings); } + /** + * 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다. + * Elasticsearch에서 Suggest API 등을 활용하여 추천 결과를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-25 + */ + @Override + public AutoCompleteStringList getSymptomAutoComplete(String q) { + log("getSymptomAutoComplete() 메서드 호출, 검색어: " + q); + return new AutoCompleteStringList(drugSearchRepositoryPort.getSymptomAutoCompleteResponse(q)); + } + + /** + * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 + * @param size 페이지 당 문서 수 + * @return 중복 제거된 약품명 리스트 + * @since 2025-04-25 + * @modified 2025-04-27 + */ + public SearchResponseList searchDrugNamesBySymptom(String q, int page, int size) { + log("searchDrugNamesBySymptom() 메서드 호출, 검색어: " + q); + List drugSymptomResponses = drugSearchRepositoryPort.searchDocsBySymptom(q, page, size) + .stream() + .map(SymptomMapper::toResponse) + .toList(); + + return new SearchResponseList(drugSymptomResponses); + } + /** * 검색어가 null이거나 빈 문자열인지 검사하고, * 유효하지 않으면 SearchException을 던진다. diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java index cd253f2..f4de8c7 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -8,7 +8,9 @@ public enum SearchErrorCode implements ErrorCode { INVALID_QUERY(HttpStatus.BAD_REQUEST, 140001, "검색어를 입력해주세요"), ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), - EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"); + EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"), + ES_SUGGEST_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "검색어 자동완성에 실패했습니다."), + ES_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "증상 검색에 실패했습니다."); private final HttpStatus status; private final int code; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java similarity index 58% rename from src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java index fc9b500..609cb55 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/DrugSymptom.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java @@ -1,4 +1,6 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model; +package com.likelion.backendplus4.yakplus.search.domain.model; + +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -12,5 +14,7 @@ public class DrugSymptom { private Long drugId; private String drugName; - private String symptom; + private List efficacy; + private String company; + private String imageUrl; } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index 8ce60d6..775b510 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -3,13 +3,21 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.SymptomMapper; + +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; +import co.elastic.clients.elasticsearch.core.search.Hit; import lombok.RequiredArgsConstructor; import org.apache.http.entity.ContentType; import org.apache.http.nio.entity.NStringEntity; +import co.elastic.clients.elasticsearch.ElasticsearchClient; import org.elasticsearch.client.Request; import org.elasticsearch.client.Response; import org.elasticsearch.client.RestClient; @@ -22,6 +30,8 @@ import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; +import java.util.Objects; + /** * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. @@ -37,6 +47,7 @@ public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { private final RestClient restClient; private final ObjectMapper objectMapper; + private final ElasticsearchClient esClient; /** * 주어진 쿼리 및 임베딩 벡터를 사용해 Elasticsearch에서 검색을 수행하고, @@ -67,6 +78,85 @@ public List searchBySymptoms(String query, float[] vector, int size, int f } } + /** + * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 + * symptomSuggester 필드에서 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트 + * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-28 + * */ + @Override + public List getSymptomAutoCompleteResponse(String q) { + log("getSymptomAutoCompleteResponse() 메서드 호출, 검색어: " + q); + SearchResponse resp; + try { + resp = esClient.search(s -> s + .index("eedoc") + .suggest(su -> su + .suggesters("symp_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("symptomSuggester") + .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 + .size(20) + ) + ) + ) + , Void.class); + } catch (IOException e) { + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); + throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); + } + + // Suggest 파싱 + return resp.suggest().get("symp_sugg") + .get(0).completion().options().stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); + } + + /** + * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 페이지 당 문서 수 + * @return 증상 문서 리스트 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-28 + * @throws SearchException 검색 중 오류 발생 시 + */ + public List searchDocsBySymptom(String q, int page, int size) { + log("searchDocsBySymptom() 메서드 호출, 검색어: " + q); + try { + SearchResponse resp = esClient.search(s -> s + .index("eedoc") + .from(page * size) + .size(size) + .query(qb -> qb + .match(m -> m + .field("efficacy") // only_nouns analyzer 적용된 필드 + .query(q) // 사용자가 입력한 q 값 + ) + ) + , DrugSymptomDocument.class); + return resp.hits().hits().stream() + .map(Hit::source) + .filter(Objects::nonNull) + .map(SymptomMapper::toDomain) + .toList(); + } catch (IOException e) { + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); + throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); + } + } + /** * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java similarity index 60% rename from src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java index 471f34b..83fa802 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/document/DrugSymptomDocument.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document; +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document; import java.util.List; @@ -8,6 +8,9 @@ import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -18,18 +21,28 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@JsonIgnoreProperties(ignoreUnknown = true) public class DrugSymptomDocument { @Id @Field(type = FieldType.Keyword, name = "ITEM_SEQ") + @JsonProperty("ITEM_SEQ") private Long drugId; @Field(type = FieldType.Text, name = "ITEM_NAME") + @JsonProperty("ITEM_NAME") private String drugName; - @Field(type = FieldType.Text, name = "symptom", analyzer = "only_nouns") - private String symptom; + @Field(type = FieldType.Text, name = "company") + private String company; + + @Field(type = FieldType.Text, name = "efficacy") + private List efficacy; + + @Field(type = FieldType.Keyword, name = "imageUrl") + private String imageUrl; @CompletionField(analyzer = "symptom_autocomplete") private List symptomSuggester; } + diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java new file mode 100644 index 0000000..5864d4b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java @@ -0,0 +1,55 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.support; + + +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; + +/** + * 증상 관련 Document를 다루는 매퍼 클래스입니다. + * + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ +public class SymptomMapper { + + /** + * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. + * + * @param symptomDocument 변환 대상 ES Document 객체 + * @return DrugSymptom 도메인 모델 객체 + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { + return DrugSymptom.builder() + .drugId(symptomDocument.getDrugId()) + .drugName(symptomDocument.getDrugName()) + .efficacy(symptomDocument.getEfficacy()) + .company(symptomDocument.getCompany()) + .imageUrl(symptomDocument.getImageUrl()) + .build(); + } + + + /** + * 도메인 모델(DrugSymptom)을 HTTP 응답용 DTO(DrugSymptomResponse)로 변환합니다. + * + * @param drugSymptom 변환 대상 도메인 객체 + * @return DrugSymptomResponse 응답 DTO 객체 + * @author 박찬병 + * @since 2025-04-25 + * @modified 2025-04-25 + */ + public static SearchResponse toResponse(DrugSymptom drugSymptom) { + return SearchResponse.builder() + .drugId(drugSymptom.getDrugId()) + .drugName(drugSymptom.getDrugName()) + .efficacy(drugSymptom.getEfficacy()) + .company(drugSymptom.getCompany()) + .imageUrl(drugSymptom.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 87a51aa..9fdbf7a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -1,14 +1,19 @@ package com.likelion.backendplus4.yakplus.search.presentation.controller; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.response.ApiResponse; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; + import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -18,7 +23,7 @@ /** * 약품 검색 API 엔드포인트를 제공하는 컨트롤러 클래스 * - * @modified 2025-04-25 + * @modified 2025-04-28 * @since 2025-04-22 */ @RestController @@ -41,4 +46,43 @@ public ResponseEntity>> search(@RequestBody Sea log("drugController 요청 수신" + searchRequest.toString()); return ApiResponse.success(searchDrugUseCase.search(searchRequest)); } + + + /** + * 사용자 입력 키워드를 바탕으로 증상 자동완성 추천 결과를 조회합니다. + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-28 + */ + @GetMapping("/autocomplete/symptom") + public ResponseEntity> autocomplete(@RequestParam String q) { + log("drugController 요청 수신" + q); + AutoCompleteStringList results = searchDrugUseCase.getSymptomAutoComplete(q); + return ApiResponse.success(results); + } + + /** + * 증상 키워드 검색으로 매칭되는 약품명 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 (기본값 0) + * @param size 페이지 당 문서 수 (기본값 10) + * @return 약품명 리스트를 담은 ApiResponse + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-28 + */ + @GetMapping("/search/symptom") + public ResponseEntity> searchNames( + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + log("drugController 요청 수신" + q); + SearchResponseList drugSymptomList = searchDrugUseCase.searchDrugNamesBySymptom(q, page, size); + return ApiResponse.success(drugSymptomList); + } + } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/AutoCompleteStringList.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/AutoCompleteStringList.java new file mode 100644 index 0000000..eb23c0f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/AutoCompleteStringList.java @@ -0,0 +1,13 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; + +import java.util.List; + +/** + * 자동완성 검색 결과 DTO + * @since 2025-04-28 + * @modified 2025-04-28 + */ +public record AutoCompleteStringList( + List autoCompleteList +) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java index 5910e87..1dd92c1 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponse.java @@ -2,6 +2,9 @@ import java.util.List; + +import lombok.Builder; + /** * 검색 결과 정보 DTO * @@ -9,6 +12,7 @@ * 25.04.27 - Drug 도메인 객체 변경에 따른 필드 수정 * @since 2025-04-22 */ +@Builder public record SearchResponse( Long drugId, String drugName, diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java new file mode 100644 index 0000000..c93b661 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java @@ -0,0 +1,13 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; + +import java.util.List; + +/** + * 검색 결과 정보 리스트를 담는 DTO + * @since 2025-04-28 + * @modified 2025-04-28 + */ +public record SearchResponseList( + List searchResponseList +) { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java deleted file mode 100644 index e6cbe2c..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/EEDocIndexController.java +++ /dev/null @@ -1,31 +0,0 @@ -// package com.likelion.backendplus4.yakplus.temp.api; -// -// 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; -// -// import com.likelion.backendplus4.yakplus.temp.application.EEDocIndexService; -// -// import lombok.RequiredArgsConstructor; -// -// @RestController -// @RequiredArgsConstructor -// @RequestMapping("/api/eedocs") -// public class EEDocIndexController { -// -// private final EEDocIndexService indexService; -// -// @PostMapping("/index") -// public ResponseEntity triggerIndex() { -// try { -// indexService.indexAll(); -// return ResponseEntity.ok("EEDoc indexing completed successfully."); -// } catch (Exception ex) { -// return ResponseEntity -// .status(500) -// .body("Indexing failed: " + ex.getMessage()); -// } -// } -// -// } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java deleted file mode 100644 index 95e6cdc..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/temp/api/SymptomController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.likelion.backendplus4.yakplus.temp.api; - -import java.io.IOException; -import java.util.List; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch.core.SearchResponse; - -import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/symptoms") -@RequiredArgsConstructor -public class SymptomController { - - private final ElasticsearchClient esClient; - - @GetMapping("/autocomplete") - public ResponseEntity> autocomplete(@RequestParam String q) throws IOException { - SearchResponse resp = esClient.search(s -> s - .index("eedoc") - .suggest(su -> su - .suggesters("symp_sugg", sg -> sg - .prefix(q) - .completion(c -> c - .field("symptomSuggester") - .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 - .size(20) - ) - ) - ) - , Void.class); - - // Suggest 파싱 - List results = resp.suggest().get("symp_sugg") - .get(0).completion().options().stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); - - return ResponseEntity.ok(results); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java deleted file mode 100644 index 71b3ca8..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/temp/application/EEDocIndexService.java +++ /dev/null @@ -1,97 +0,0 @@ -// package com.likelion.backendplus4.yakplus.temp.application; -// -// import java.io.IOException; -// import java.util.Arrays; -// import java.util.List; -// import java.util.Objects; -// import java.util.Set; -// import java.util.stream.Collectors; -// -// import org.springframework.stereotype.Service; -// import org.springframework.transaction.annotation.Transactional; -// -// import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.GovDrungDetailJpaRepository; -// import com.likelion.backendplus4.yakplus.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; -// import com.likelion.backendplus4.yakplus.temp.dao.EEDocRepository; -// import com.likelion.backendplus4.yakplus.temp.util.JsonTextExtractor; -// import com.likelion.backendplus4.yakplus.temp.entity.document.EEDocDocument; -// -// import lombok.RequiredArgsConstructor; -// import lombok.extern.slf4j.Slf4j; -// -// @Slf4j -// @Service -// @RequiredArgsConstructor -// public class EEDocIndexService { -// -// private final GovDrungDetailJpaRepository drugRepository; -// private final EEDocRepository docRepository; -// -// @Transactional -// public void indexAll() { -// // TODO 배치 처리 -// List docs = drugRepository.findAll().stream() -// .map(this::toDocument) -// .filter(Objects::nonNull) -// .toList(); -// -// docRepository.saveAll(docs); -// } -// -// /** -// * DB 엔티티 → ES Document 변환 -// */ -// private EEDocDocument toDocument(GovDrugDetailEntity entity) { -// List raws = extractRawLines(entity); -// if (raws == null) -// return null; -// -// String flatText = flattenLines(raws); -// List suggestTokens = tokenizeForSuggestion(flatText); -// -// return EEDocDocument.builder() -// .drugId(entity.getDrugId().toString()) -// .drugName(entity.getDrugName()) -// .symptom(flatText) -// .symptomSuggester(suggestTokens) -// .build(); -// } -// -// /** -// * JSON 파싱 및 텍스트 추출 -// */ -// private List extractRawLines(GovDrugDetailEntity entity) { -// try { -// return JsonTextExtractor.extractAllTexts(entity.getEfficacy()); -// } catch (IOException ex) { -// log.warn("효능효과 JSON 파싱 실패 id={}", entity.getDrugId()); -// return null; -// } -// } -// -// /** -// * 번호·헤더·기호 제거 후 한 줄로 합치기 -// */ -// private String flattenLines(List raws) { -// return raws.stream() -// .map(line -> line.replaceAll("^\\d+\\.\\s*|효능효과|[○•▶]", " ")) -// .collect(Collectors.joining(" ")); -// } -// -// /** -// * 자동완성/제안용 토큰 생성 -// */ -// private List tokenizeForSuggestion(String text) { -// return Arrays.stream(text.split("[,·/;:\\s()\\[\\]]+")) -// .map(String::trim) -// // 길이 2자 이상 -// .filter(tok -> tok.length() >= 2) -// // 불용어 제거 -// .filter(tok -> !Set.of("특히", "등의", "또는", "및", "의한").contains(tok)) -// // 조사 제거 -// .map(tok -> tok.replaceAll("(?.+?)(?:의|에|으로|에서|시의)$", "${base}")) -// // 중복 제거 -// .distinct() -// .toList(); -// } -// } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java deleted file mode 100644 index 4effbfd..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/temp/dao/EEDocRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.temp.dao; - - -import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; - -import com.likelion.backendplus4.yakplus.temp.entity.document.EEDocDocument; - -public interface EEDocRepository extends ElasticsearchRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java deleted file mode 100644 index d2f0f8a..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/temp/entity/document/EEDocDocument.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.likelion.backendplus4.yakplus.temp.entity.document; - -import java.util.List; - -import org.springframework.data.annotation.Id; -import org.springframework.data.elasticsearch.annotations.CompletionField; -import org.springframework.data.elasticsearch.annotations.Document; -import org.springframework.data.elasticsearch.annotations.Field; -import org.springframework.data.elasticsearch.annotations.FieldType; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; -import lombok.Builder; - -@Document(indexName = "eedoc") -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class EEDocDocument { - - @Id - @Field(type = FieldType.Keyword, name = "ITEM_SEQ") - private String drugId; - - @Field(type = FieldType.Text, name = "ITEM_NAME") - private String drugName; - - @Field(type = FieldType.Text, name = "symptom", analyzer = "only_nouns") - private String symptom; - - // ↓ 프로퍼티명을 "symptomSuggester"로 변경해야 ES 매핑과 일치합니다 - @CompletionField(analyzer = "symptom_autocomplete") - private List symptomSuggester; -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4ca476e..bb80955 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: create + ddl-auto: update # ddl-auto: create # show-sql: true properties: From 4fd61cb853a184a0abf37017cf6b7f476daadeb3 Mon Sep 17 00:00:00 2001 From: HaechangLee <112938092+HaechangLee@users.noreply.github.com> Date: Tue, 29 Apr 2025 18:14:05 +0900 Subject: [PATCH 18/25] =?UTF-8?q?=E2=9C=A8=20=20Feat:=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EB=8C=80=EC=83=81=20ES=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/persistence/ElasticsearchDrugAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index 775b510..573ecc2 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -43,7 +43,7 @@ @Component @RequiredArgsConstructor public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { - private static final String SEARCH_INDEX = "drugs"; + private static final String SEARCH_INDEX = "test-gpt"; private final RestClient restClient; private final ObjectMapper objectMapper; From 563b5b1258f7c9c2a41790011bacf91f5b78da10 Mon Sep 17 00:00:00 2001 From: chanbyeong <122460524+chanbyoung@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:12:34 +0900 Subject: [PATCH 19/25] =?UTF-8?q?=E2=9C=A8=20Feature:=20#5=20=EC=95=BD?= =?UTF-8?q?=ED=92=88=EB=AA=85=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feat: 약품명 Document 객체 정의 * ✨ Feat: 약품명 검색 및 자동완성 기능 구현 * ♻️ Refactor: 메서드 명 및 컨트롤러 코드 `PathVariable` 사용하여 통합 * ✨ Feat: 주석 및 로그 추가 * ✨ Feat: 검색 타입 enum 추가 --- .../port/in/SearchDrugUseCase.java | 6 +- .../port/out/DrugSearchRepositoryPort.java | 11 +- .../application/service/DrugSearcher.java | 89 ++++++++++---- .../exception/error/SearchErrorCode.java | 1 + ...DrugSymptom.java => DrugSearchDomain.java} | 2 +- .../persistence/ElasticsearchDrugAdapter.java | 115 ++++++++++++++++-- .../document/DrugNameDocument.java | 48 ++++++++ .../{SymptomMapper.java => DrugMapper.java} | 31 +++-- .../controller/DrugController.java | 62 +++++++--- .../presentation/controller/SearchType.java | 17 +++ .../dto/response/SearchResponseList.java | 3 +- 11 files changed, 328 insertions(+), 57 deletions(-) rename src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/{DrugSymptom.java => DrugSearchDomain.java} (92%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java rename src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/{SymptomMapper.java => DrugMapper.java} (58%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java index 926a0d2..70a0c38 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -12,6 +12,10 @@ public interface SearchDrugUseCase { AutoCompleteStringList getSymptomAutoComplete(String q); - SearchResponseList searchDrugNamesBySymptom(String q, int page, int size); + AutoCompleteStringList getDrugNameAutoComplete(String q); + + SearchResponseList searchDrugByDrugName(String q, int page, int size); + + SearchResponseList searchDrugBySymptom(String q, int page, int size); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java index 65a7ab3..7c98e1d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java @@ -1,13 +1,20 @@ package com.likelion.backendplus4.yakplus.search.application.port.out; import java.util.List; + +import org.springframework.data.domain.Page; + import com.likelion.backendplus4.yakplus.search.domain.model.Drug; -import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; public interface DrugSearchRepositoryPort { List searchBySymptoms(String query, float[] vector, int from, int size); List getSymptomAutoCompleteResponse(String q); - List searchDocsBySymptom(String q, int page, int size); + Page searchDocsBySymptom(String q, int page, int size); + + List getDrugNameAutoCompleteResponse(String q); + + Page searchDocsByDrugName(String q, int page, int size); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index 1cc5dac..bd628d5 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -1,7 +1,8 @@ package com.likelion.backendplus4.yakplus.search.application.service; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; -import com.likelion.backendplus4.yakplus.search.infrastructure.support.SymptomMapper; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; @@ -14,6 +15,8 @@ import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.util.List; @@ -25,7 +28,7 @@ * 사용자 검색 요청을 처리하고, 벡터 유사도 및 텍스트 검색을 통해 * 결과를 반환하는 서비스 구현체 * - * @modified 2025-04-28 + * @modified 2025-04-29 * @since 2025-04-22 */ @Service @@ -69,24 +72,67 @@ public AutoCompleteStringList getSymptomAutoComplete(String q) { return new AutoCompleteStringList(drugSearchRepositoryPort.getSymptomAutoCompleteResponse(q)); } + /** + * 사용자 입력 문자열을 바탕으로 약품명 자동완성 추천 키워드를 조회합니다. + * + * Elasticsearch Suggest API를 활용하여 약품명과 관련된 자동완성 키워드 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO (AutoCompleteStringList) + * @author 박찬병 + * @since 2025-04-29 + * @modified 2025-04-30 + */ + @Override + public AutoCompleteStringList getDrugNameAutoComplete(String q) { + log("getDrugNameAutoComplete() 메서드 호출, 검색어: " + q); + return new AutoCompleteStringList(drugSearchRepositoryPort.getDrugNameAutoCompleteResponse(q)); + } + /** * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. * * @param q 검색어 프리픽스 * @param page 조회할 페이지 번호 * @param size 페이지 당 문서 수 - * @return 중복 제거된 약품명 리스트 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 * @since 2025-04-25 * @modified 2025-04-27 */ - public SearchResponseList searchDrugNamesBySymptom(String q, int page, int size) { - log("searchDrugNamesBySymptom() 메서드 호출, 검색어: " + q); - List drugSymptomResponses = drugSearchRepositoryPort.searchDocsBySymptom(q, page, size) - .stream() - .map(SymptomMapper::toResponse) - .toList(); - - return new SearchResponseList(drugSymptomResponses); + public SearchResponseList searchDrugBySymptom(String q, int page, int size) { + log("searchDrugBySymptom() 메서드 호출, 검색어: " + q); + Page drugPage = drugSearchRepositoryPort.searchDocsBySymptom(q, page, size); + return new SearchResponseList( + drugPage.getContent().stream() + .map(DrugMapper::toResponse) + .toList(), + drugPage.getTotalElements() + ); + } + + /** + * 주어진 약품명 키워드로 약품 리스트를 검색하여 반환합니다. + * + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 + * @param size 페이지 당 조회할 문서 수 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 + * @since 2025-04-29 + * @modified 2025-04-30 + */ + @Override + public SearchResponseList searchDrugByDrugName(String q, int page, int size) { + log("searchDrugByDrugName() 메서드 호출, 검색어: " + q); + Page drugPage = drugSearchRepositoryPort.searchDocsByDrugName(q, page, size); + + return new SearchResponseList( + drugPage.getContent().stream() + .map(DrugMapper::toResponse) + .toList(), + drugPage.getTotalElements() + ); } /** @@ -134,7 +180,8 @@ private float[] generateEmbeddings(String query) { */ private List searchDrugs(SearchRequest searchRequest, float[] embeddings) { log("searchDrugs() 메서드 호출, 검색어: " + searchRequest.query()); - List drugs = drugSearchRepositoryPort.searchBySymptoms(searchRequest.query(), embeddings, searchRequest.size(), searchRequest.page() * searchRequest.size()); + List drugs = drugSearchRepositoryPort.searchBySymptoms(searchRequest.query(), embeddings, + searchRequest.size(), searchRequest.page() * searchRequest.size()); log("searchDrugs() 메서드 완료, 검색어: " + searchRequest.query() + ", 검색 결과 개수: " + drugs.size()); return mapToDrugDomain(drugs); } @@ -152,13 +199,13 @@ private List searchDrugs(SearchRequest searchRequest, float[] em */ private List mapToDrugDomain(List drugs) { return drugs.stream() - .map(d -> new SearchResponse( - d.getDrugId(), - d.getDrugName(), - d.getCompany(), - d.getEfficacy(), - d.getImageUrl() - )) - .collect(Collectors.toList()); + .map(d -> new SearchResponse( + d.getDrugId(), + d.getDrugName(), + d.getCompany(), + d.getEfficacy(), + d.getImageUrl() + )) + .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java index f4de8c7..b103530 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -7,6 +7,7 @@ @RequiredArgsConstructor public enum SearchErrorCode implements ErrorCode { INVALID_QUERY(HttpStatus.BAD_REQUEST, 140001, "검색어를 입력해주세요"), + INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 140002, "지원하지 않는 검색 타입입니다."), ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"), ES_SUGGEST_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "검색어 자동완성에 실패했습니다."), diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java similarity index 92% rename from src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java index 609cb55..dde7931 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSymptom.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java @@ -11,7 +11,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class DrugSymptom { +public class DrugSearchDomain { private Long drugId; private String drugName; private List efficacy; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index 573ecc2..ce2d671 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -3,13 +3,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; -import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; -import com.likelion.backendplus4.yakplus.search.infrastructure.support.SymptomMapper; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; @@ -21,6 +22,9 @@ import org.elasticsearch.client.Request; import org.elasticsearch.client.Response; import org.elasticsearch.client.RestClient; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; import java.io.IOException; @@ -36,8 +40,9 @@ * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. * - * @modified 2025-04-27 + * @modified 2025-04-29 * 25.04.27 - searchBySymptoms() 메서드 리팩토링 + * 25.04.29 - 약품명 검색 기능 추가 * @since 2025-04-22 */ @Component @@ -102,7 +107,7 @@ public List getSymptomAutoCompleteResponse(String q) { .completion(c -> c .field("symptomSuggester") .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 - .size(20) + .size(10) ) ) ) @@ -132,7 +137,7 @@ public List getSymptomAutoCompleteResponse(String q) { * @modified 2025-04-28 * @throws SearchException 검색 중 오류 발생 시 */ - public List searchDocsBySymptom(String q, int page, int size) { + public Page searchDocsBySymptom(String q, int page, int size) { log("searchDocsBySymptom() 메서드 호출, 검색어: " + q); try { SearchResponse resp = esClient.search(s -> s @@ -146,17 +151,113 @@ public List searchDocsBySymptom(String q, int page, int size) { ) ) , DrugSymptomDocument.class); - return resp.hits().hits().stream() + + List results = resp.hits().hits().stream() .map(Hit::source) .filter(Objects::nonNull) - .map(SymptomMapper::toDomain) + .map(DrugMapper::toDomainBySymptomDocument) .toList(); + + long totalHits = resp.hits().total().value(); + + return new PageImpl<>( + results, + PageRequest.of(page, size), + totalHits + ); } catch (IOException e) { log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); } } + /** + * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 + * drugNameSuggester 필드에서 약품명 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 키워드 리스트 + * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @since 2025-04-28 + * @modified 2025-04-29 + */ + @Override + public List getDrugNameAutoCompleteResponse(String q) { + log("getDrugNameAutoCompleteResponse() 메서드 호출, 검색어: " + q); + try { + SearchResponse resp = esClient.search(s -> s + .index("drug_name") + .suggest(su -> su + .suggesters("name_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("drugNameSuggester") + .analyzer("drugName_autocomplete") + .size(20) + ) + ) + ), + Void.class + ); + var options = resp.suggest().get("name_sugg").get(0).completion().options(); + return options.stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); + } catch (IOException e) { + throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); + } + } + + /** + * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. + * + * matchPhrasePrefix 쿼리를 사용하여 약품명 필드에서 접두어 기반 검색을 수행합니다. + * + * @param q 검색어 프리픽스 (사용자 입력) + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 페이지 당 조회할 문서 수 + * @return 검색된 약품명 문서 리스트를 담은 Page 객체 (DrugSearchDomain) + * @throws SearchException 검색 중 오류 발생 시 + * @since 2025-04-28 + * @modified 2025-04-29 + */ + @Override + public Page searchDocsByDrugName(String q, int page, int size) { + log("searchDocsByItemName() called, query: " + q); + try { + SearchResponse resp = esClient.search(s -> s + .index("drug_name") + .from(page * size) + .size(size) + .query(qb -> qb + .matchPhrasePrefix(mpp -> mpp + .field("drugName") + .query(q) + ) + ), + DrugNameDocument.class + ); + + List results = resp.hits().hits().stream() + .map(Hit::source) + .filter(Objects::nonNull) + .map(DrugMapper::toDomainByNameDocument) + .toList(); + + long totalHits = resp.hits().total().value(); + + return new PageImpl<>( + results, + PageRequest.of(page, size), + totalHits + ); + } catch (IOException e) { + throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); + } + } + /** * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java new file mode 100644 index 0000000..262e239 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java @@ -0,0 +1,48 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.CompletionField; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Document(indexName = "drug_name") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class DrugNameDocument { + + @Id + @Field(type = FieldType.Keyword, name = "drugId") + private Long drugId; + + @Field(type = FieldType.Text, name = "drugName") + private String drugName; + + @Field(type = FieldType.Keyword, name = "company") + private String company; + + @Field(type = FieldType.Text, name = "efficacy") + private List efficacy; + + @Field(type = FieldType.Keyword, name = "imageUrl") + private String imageUrl; + + + @CompletionField( + analyzer = "drugName_autocomplete", + searchAnalyzer = "drugName_autocomplete" + ) + private List drugNameSuggester; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java similarity index 58% rename from src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java index 5864d4b..63171d2 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/SymptomMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java @@ -1,18 +1,19 @@ package com.likelion.backendplus4.yakplus.search.infrastructure.support; -import com.likelion.backendplus4.yakplus.search.domain.model.DrugSymptom; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; /** - * 증상 관련 Document를 다루는 매퍼 클래스입니다. + * 증상 관련 객체를 다루는 매퍼 클래스입니다. * * @author 박찬병 * @since 2025-04-25 - * @modified 2025-04-25 + * @modified 2025-04-29 */ -public class SymptomMapper { +public class DrugMapper { /** * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. @@ -23,8 +24,8 @@ public class SymptomMapper { * @since 2025-04-25 * @modified 2025-04-25 */ - public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { - return DrugSymptom.builder() + public static DrugSearchDomain toDomainBySymptomDocument(DrugSymptomDocument symptomDocument) { + return DrugSearchDomain.builder() .drugId(symptomDocument.getDrugId()) .drugName(symptomDocument.getDrugName()) .efficacy(symptomDocument.getEfficacy()) @@ -33,6 +34,22 @@ public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { .build(); } + /** + * ES 색인용 Document를 도메인 모델(DrugSearchDomain)로 변환합니다. + * + * @param nameDocument 변환 대상 ES Document 객체 (약품명 전용) + * @return DrugSearchDomain 도메인 모델 객체 + */ + public static DrugSearchDomain toDomainByNameDocument(DrugNameDocument nameDocument) { + return DrugSearchDomain.builder() + .drugId(nameDocument.getDrugId()) + .drugName(nameDocument.getDrugName()) + .efficacy(nameDocument.getEfficacy()) + .company(nameDocument.getCompany()) + .imageUrl(nameDocument.getImageUrl()) + .build(); + } + /** * 도메인 모델(DrugSymptom)을 HTTP 응답용 DTO(DrugSymptomResponse)로 변환합니다. @@ -43,7 +60,7 @@ public static DrugSymptom toDomain(DrugSymptomDocument symptomDocument) { * @since 2025-04-25 * @modified 2025-04-25 */ - public static SearchResponse toResponse(DrugSymptom drugSymptom) { + public static SearchResponse toResponse(DrugSearchDomain drugSymptom) { return SearchResponse.builder() .drugId(drugSymptom.getDrugId()) .drugName(drugSymptom.getDrugName()) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 9fdbf7a..1be640d 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -1,5 +1,7 @@ package com.likelion.backendplus4.yakplus.search.presentation.controller; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.response.ApiResponse; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; @@ -10,6 +12,7 @@ import lombok.RequiredArgsConstructor; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,7 +26,7 @@ /** * 약품 검색 API 엔드포인트를 제공하는 컨트롤러 클래스 * - * @modified 2025-04-28 + * @modified 2025-04-29 * @since 2025-04-22 */ @RestController @@ -47,42 +50,67 @@ public ResponseEntity>> search(@RequestBody Sea return ApiResponse.success(searchDrugUseCase.search(searchRequest)); } - /** - * 사용자 입력 키워드를 바탕으로 증상 자동완성 추천 결과를 조회합니다. + * 사용자 입력 키워드를 바탕으로 자동완성 추천 결과를 조회합니다. + * + * 검색 타입(type)에 따라 증상 자동완성 또는 약품명 자동완성 결과를 반환합니다. * - * @param q 검색어 프리픽스 + * @param type 자동완성 타입 (symptom 또는 name) + * @param q 검색어 프리픽스 * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse + * @throws SearchException 지원하지 않는 타입 입력 시 예외 발생 + * * @author 박찬병 * @since 2025-04-24 - * @modified 2025-04-28 + * @modified 2025-04-29 */ - @GetMapping("/autocomplete/symptom") - public ResponseEntity> autocomplete(@RequestParam String q) { - log("drugController 요청 수신" + q); - AutoCompleteStringList results = searchDrugUseCase.getSymptomAutoComplete(q); + @GetMapping("/autocomplete/{type}") + public ResponseEntity> autocomplete( + @PathVariable String type, + @RequestParam String q) { + + log("drugController 요청 수신 - type: " + type + ", query: " + q); + + SearchType searchType = SearchType.from(type); + + AutoCompleteStringList results = switch (searchType) { + case SYMPTOM -> searchDrugUseCase.getSymptomAutoComplete(q); + case NAME -> searchDrugUseCase.getDrugNameAutoComplete(q); + }; + return ApiResponse.success(results); } /** - * 증상 키워드 검색으로 매칭되는 약품명 리스트를 반환합니다. + * 증상 또는 약품명 검색을 통해 매칭되는 약품 리스트를 조회합니다. * + * @param type 검색 타입 (symptom 또는 name) * @param q 검색어 프리픽스 * @param page 조회할 페이지 번호 (기본값 0) * @param size 페이지 당 문서 수 (기본값 10) - * @return 약품명 리스트를 담은 ApiResponse + * @return 검색 결과를 담은 ApiResponse + * @throws SearchException 지원하지 않는 검색 타입 입력 시 예외 발생 * @author 박찬병 * @since 2025-04-24 - * @modified 2025-04-28 + * @modified 2025-04-29 */ - @GetMapping("/search/symptom") - public ResponseEntity> searchNames( + @GetMapping("/search/{type}") + public ResponseEntity> searchDrugs( + @PathVariable String type, @RequestParam String q, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { - log("drugController 요청 수신" + q); - SearchResponseList drugSymptomList = searchDrugUseCase.searchDrugNamesBySymptom(q, page, size); - return ApiResponse.success(drugSymptomList); + + log("drugController 요청 수신 - type: " + type + ", query: " + q); + + SearchType searchType = SearchType.from(type); + + SearchResponseList results = switch (searchType) { + case SYMPTOM -> searchDrugUseCase.searchDrugBySymptom(q, page, size); + case NAME -> searchDrugUseCase.searchDrugByDrugName(q, page, size); + }; + + return ApiResponse.success(results); } } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java new file mode 100644 index 0000000..cc81953 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java @@ -0,0 +1,17 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller; + +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; + +public enum SearchType { + SYMPTOM, + NAME; + + public static SearchType from(String type) { + try { + return SearchType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new SearchException(SearchErrorCode.INVALID_SEARCH_TYPE); + } + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java index c93b661..69dafda 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/SearchResponseList.java @@ -8,6 +8,7 @@ * @modified 2025-04-28 */ public record SearchResponseList( - List searchResponseList + List searchResponseList, + long totalResponseCount ) { } From c3a459f61b9c8866e88def755cd56a1a0177b8e6 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Wed, 30 Apr 2025 17:40:17 +0900 Subject: [PATCH 20/25] =?UTF-8?q?=20=E2=9C=A8Feature:=20#59=20=EC=95=BD?= =?UTF-8?q?=ED=92=88=20id=20rdb=20=EC=A1=B0=ED=9A=8C=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Feature: 약품id 조회 추가 * 📦 Chore: 주석 추가 --- .../service/DrugDataServiceImpl.java | 2 +- .../repository/jpa/GovDrugJpaRepository.java | 8 -- .../support/mapper/DrugDataMapper.java | 19 +++- .../port/in/SearchDrugUseCase.java | 19 ++++ .../port/out/DrugSearchRdbRepositoryPort.java | 28 +++++ .../application/service/DrugSearcher.java | 17 +++ .../exception/error/SearchErrorCode.java | 1 + .../yakplus/search/domain/model/Drug.java | 3 + .../adapter/persistence/DrugJpaAdapter.java} | 18 ++- .../persistence}/entity/GovDrugEntity.java | 11 +- .../persistence/jpa/GovDrugJpaRepository.java | 12 ++ .../infrastructure/support/DrugMapper.java | 103 +++++++++++++++++- .../controller/DrugController.java | 16 ++- .../dto/request/SearchRequestById.java | 8 ++ .../dto/response/DetailSearchResponse.java | 35 ++++++ src/main/resources/application.yml | 2 +- 16 files changed, 284 insertions(+), 18 deletions(-) delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java rename src/main/java/com/likelion/backendplus4/yakplus/{drug/infrastructure/adapter/GovDrugJpaAdapter.java => search/infrastructure/adapter/persistence/DrugJpaAdapter.java} (57%) rename src/main/java/com/likelion/backendplus4/yakplus/{drug/infrastructure/adapter/persistence/repository => search/infrastructure/adapter/persistence}/entity/GovDrugEntity.java (83%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequestById.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java index 9ce3101..13dba49 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Service; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugJpaRepository; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa.GovDrugJpaRepository; import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java deleted file mode 100644 index b6ab711..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugEntity; - -public interface GovDrugJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java index 463e0d6..a1c32d7 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java @@ -1,9 +1,25 @@ package com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugEntity; import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; +/** + * GovDrugEntity와 도메인 모델(GovDrug) 간의 매핑을 담당하는 클래스. + * Entity 객체를 비즈니스 도메인 객체로 변환한다. + * + * @since 2025-04-30 + */ public class DrugDataMapper { + + /** + * GovDrugEntity로부터 GovDrug 도메인 객체를 생성합니다. + * + * @param e 변환할 GovDrugEntity 객체 + * @return 변환된 GovDrug 도메인 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ public static GovDrug toDomainFromEntity(GovDrugEntity e) { return GovDrug.builder() .drugId(e.getId()) @@ -21,5 +37,4 @@ public static GovDrug toDomainFromEntity(GovDrugEntity e) { .build(); } - } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java index 70a0c38..aab8f41 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -2,12 +2,31 @@ import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; import java.util.List; +/** + * 의약품 검색 관련 유스케이스를 정의하는 인터페이스입니다. + * 다양한 조건에 따라 의약품을 검색하거나 자동완성 기능을 제공합니다. + * + * @since 2025-04-21 + */ public interface SearchDrugUseCase { + + /** + * 의약품 ID를 통해 상세 정보를 조회합니다. + * + * @param drugId 조회할 의약품의 고유 ID + * @return 상세 검색 응답 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ + DetailSearchResponse searchByDrugId(Long drugId); + List search(SearchRequest searchRequest); AutoCompleteStringList getSymptomAutoComplete(String q); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java new file mode 100644 index 0000000..dbf91e6 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java @@ -0,0 +1,28 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; + +/** + * RDB 기반 의약품 검색을 위한 포트 인터페이스입니다. + * 데이터베이스로부터 의약품 정보를 조회하는 기능을 정의합니다. + * + * @since 2025-04-30 + */ +public interface DrugSearchRdbRepositoryPort { + Page findAllDrugs(Pageable pageable); + + /** + * ID를 기반으로 단일 의약품 정보를 조회합니다. + * + * @param id 조회할 의약품의 고유 ID + * @return 조회된 의약품 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ + Drug findById(Long id); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index bd628d5..c3c7ac4 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -1,6 +1,7 @@ package com.likelion.backendplus4.yakplus.search.application.service; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRdbRepositoryPort; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; @@ -11,6 +12,7 @@ import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; @@ -36,7 +38,22 @@ public class DrugSearcher implements SearchDrugUseCase { private final DrugSearchRepositoryPort drugSearchRepositoryPort; private final EmbeddingPort embeddingPort; + private final DrugSearchRdbRepositoryPort drugSearchRdbRepositoryPort; + /** + * 의약품 ID를 통해 상세 정보를 조회합니다. + * + * @param drugId 조회할 의약품의 고유 ID + * @return 변환된 상세 검색 응답 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ + @Override + public DetailSearchResponse searchByDrugId(Long drugId){ + Drug drug = drugSearchRdbRepositoryPort.findById(drugId); + return DrugMapper.toDetailResponse(drug); + } /** * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 * 리스트로 매핑하여 반환한다. diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java index b103530..8dbbca7 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -9,6 +9,7 @@ public enum SearchErrorCode implements ErrorCode { INVALID_QUERY(HttpStatus.BAD_REQUEST, 140001, "검색어를 입력해주세요"), INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 140002, "지원하지 않는 검색 타입입니다."), ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), + RDB_SEARCH_ERROR(HttpStatus.NO_CONTENT, 430003, "검색 결과가 없습니다"), EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"), ES_SUGGEST_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "검색어 자동완성에 실패했습니다."), ES_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "증상 검색에 실패했습니다."); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java index 661c21a..8200b90 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java @@ -26,4 +26,7 @@ public class Drug { private List usage; private Map> precaution; private String imageUrl; + private LocalDate cancelDate; + private String cancelName; + private boolean isHerbal; } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java similarity index 57% rename from src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java index 0ff761d..70bbc8f 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/GovDrugJpaAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter; +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; import org.springframework.data.domain.Page; @@ -6,8 +6,13 @@ import org.springframework.stereotype.Component; import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugJpaRepository; import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; +import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRdbRepositoryPort; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa.GovDrugJpaRepository; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import lombok.RequiredArgsConstructor; @@ -20,7 +25,7 @@ */ @Component @RequiredArgsConstructor -public class GovDrugJpaAdapter { +public class DrugJpaAdapter implements DrugSearchRdbRepositoryPort { private final GovDrugJpaRepository drugJpaRepository; @@ -35,8 +40,15 @@ public class GovDrugJpaAdapter { * @modified 2025-04-25 * */ + @Override public Page findAllDrugs(Pageable pageable) { return drugJpaRepository.findAll(pageable) .map(DrugDataMapper::toDomainFromEntity); } + + @Override + public Drug findById(Long id) { + return DrugMapper.toDomainFromEntity( + drugJpaRepository.findById(id).orElseThrow(() -> new SearchException(SearchErrorCode.RDB_SEARCH_ERROR))); + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java similarity index 83% rename from src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java index c879ccf..ec768dd 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugEntity.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity; import java.time.LocalDate; @@ -57,4 +57,13 @@ public class GovDrugEntity { @Column(name= "IMG_URL") private String imageUrl; + + @Column(name = "CANCEL_DATE") + private LocalDate cancelDate; + + @Column(name = "CANCEL_NAME") + private String cancelName; + + @Column(name = "IS_HERBAL") + private boolean isHerbal; } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java new file mode 100644 index 0000000..93c2286 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java @@ -0,0 +1,12 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; +/** + * 의약품 정보(GovDrugEntity)에 대한 JPA 레포지토리 인터페이스. + * + * @since 2025-04-30 + */ +public interface GovDrugJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java index 63171d2..5d42f43 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java @@ -1,9 +1,16 @@ package com.likelion.backendplus4.yakplus.search.infrastructure.support; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; /** @@ -14,7 +21,7 @@ * @modified 2025-04-29 */ public class DrugMapper { - + private static final ObjectMapper objectMapper = new ObjectMapper(); /** * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. * @@ -69,4 +76,98 @@ public static SearchResponse toResponse(DrugSearchDomain drugSymptom) { .imageUrl(drugSymptom.getImageUrl()) .build(); } + + /** + * Drug 도메인 객체를 DetailSearchResponse DTO로 변환합니다. + * + * @param d 변환할 Drug 객체 + * @return DetailSearchResponse 응답 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ + public static DetailSearchResponse toDetailResponse(Drug d) { + return DetailSearchResponse.builder() + .drugId(d.getDrugId()) + .drugName(d.getDrugName()) + .company(d.getCompany()) + .efficacy(d.getEfficacy()) + .permitDate(d.getPermitDate()) + .isGeneral(d.isGeneral()) + .materialInfo(d.getMaterialInfo()) + .storeMethod(d.getStoreMethod()) + .validTerm(d.getValidTerm()) + .usage(d.getUsage()) + .precaution(d.getPrecaution()) + .imageUrl(d.getImageUrl()) + .cancelDate(d.getCancelDate()) + .cancelName(d.getCancelName()) + .isGeneral(d.isGeneral()) + .build(); + } + + /** + * GovDrugEntity 엔티티 객체를 Drug 도메인 객체로 변환합니다. + * + * @param d 변환할 GovDrugEntity 객체 + * @return Drug 도메인 객체 + * + * @author 함예정 + * @since 2025-04-30 + */ + public static Drug toDomainFromEntity(GovDrugEntity d) { + return Drug.builder() + .drugId(d.getId()) + .drugName(d.getDrugName()) + .company(d.getCompany()) + .efficacy(toListFromString(d.getEfficacy())) + .permitDate(d.getPermitDate()) + .isGeneral(d.isGeneral()) + .materialInfo(toListFromString(d.getMaterialInfo())) + .storeMethod(d.getStoreMethod()) + .validTerm(d.getValidTerm()) + .usage(toListFromString(d.getUsage())) + .precaution(toMapFromString(d.getPrecaution())) + .imageUrl(d.getImageUrl()) + .cancelDate(d.getCancelDate()) + .cancelName(d.getCancelName()) + .isGeneral(d.isGeneral()) + .build(); + } + + /** + * JSON 문자열을 List 객체로 파싱합니다. + * + * @param str JSON 형식의 문자열 + * @return 파싱된 List 객체, 실패 시 null 반환 + * + * @author 함예정 + * @since 2025-04-30 + */ + private static List toListFromString(String str){ + try { + return objectMapper.readValue(str, List.class); + } catch (Exception e){ + e.printStackTrace(); + return null; + } + } + + /** + * JSON 문자열을 Map 객체로 파싱합니다. + * + * @param str JSON 형식의 문자열 + * @return 파싱된 Map 객체, 실패 시 null 반환 + * + * @author 함예정 + * @since 2025-04-30 + */ + private static Map toMapFromString(String str){ + try { + return objectMapper.readValue(str, Map.class); + } catch (Exception e){ + e.printStackTrace(); + return null; + } + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 1be640d..33bbc0e 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -6,6 +6,7 @@ import com.likelion.backendplus4.yakplus.response.ApiResponse; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; @@ -34,7 +35,6 @@ @RequiredArgsConstructor public class DrugController { private final SearchDrugUseCase searchDrugUseCase; - /** * 약품 검색 요청을 처리하여 검색 결과를 반환한다. * @@ -113,4 +113,18 @@ public ResponseEntity> searchDrugs( return ApiResponse.success(results); } + /** + * 의약품 ID를 통해 상세 정보를 조회하는 API입니다. + * + * @param id 조회할 의약품의 고유 ID + * @return 의약품 상세 정보가 담긴 ApiResponse + * + * @author 함예정 + * @since 2025-04-30 + */ + @GetMapping("/search/detail/{id}") + public ResponseEntity> searchDetail(@PathVariable Long id){ + return ApiResponse.success(searchDrugUseCase.searchByDrugId(id)); + } + } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequestById.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequestById.java new file mode 100644 index 0000000..5ff644a --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/request/SearchRequestById.java @@ -0,0 +1,8 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request; + +import lombok.Getter; + +@Getter +public class SearchRequestById { + Long durgId; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java new file mode 100644 index 0000000..26fd420 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java @@ -0,0 +1,35 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; + +import lombok.Builder; + +/** + * 검색 결과 정보 DTO + * + * @modified 2025-04-27 + * 25.04.27 - Drug 도메인 객체 변경에 따른 필드 수정 + * @since 2025-04-22 + */ +@Builder +public record DetailSearchResponse( + Long drugId, + String drugName, + String company, + List efficacy, + LocalDate permitDate, + boolean isGeneral, + List materialInfo, + String storeMethod, + String validTerm, + List usage, + Map> precaution, + String imageUrl, + LocalDate cancelDate, + String cancelName, + boolean isHerbal) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bb80955..5ba6294 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: update + ddl-auto: none # ddl-auto: create # show-sql: true properties: From 57b5a9a1ba6b9f3549e65a574c5174379bd65161 Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Sat, 3 May 2025 13:04:43 +0900 Subject: [PATCH 21/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=9E=90=EC=97=B0=EC=96=B4=20=EA=B2=80=EC=83=89=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20=EC=8A=A4?= =?UTF-8?q?=EC=9C=84=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/log/LoggerWithTraceId.java | 4 +- .../port/in/SearchDrugUseCase.java | 3 +- .../port/out/DrugSearchRepositoryPort.java | 4 +- .../port/out/dto/SearchByNaturalParams.java | 21 + .../mapper/SearchByNaturalParamsMapper.java | 11 + .../application/service/DrugSearcher.java | 149 +++---- .../exception/error/SearchErrorCode.java | 30 +- .../domain/model/DrugSearchDomainNatural.java | 19 + .../domain/model/DrugSearchNatural.java | 34 ++ .../persistence/ElasticsearchDrugAdapter.java | 388 +++++++++--------- .../natural/DrugSearchNaturalMapper.java | 28 ++ .../support/natural/SearchNaturalMapper.java | 14 + .../controller/DrugController.java | 68 ++- .../mapper/SearchRequestMapper.java | 24 ++ .../mapper/SearchResponseMapper.java | 33 ++ .../switcher/application/EmbeddingRouter.java | 28 ++ .../port/in/EmbeddingRoutingUseCase.java | 6 + .../port/out/EmbeddingSwitchPort.java | 6 + .../route/adapter/EmbeddingRouterAdapter.java | 60 +++ .../controller/EmbeddingRouterController.java | 30 ++ 20 files changed, 625 insertions(+), 335 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchRequestMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java index 75d2e98..7be5334 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java @@ -76,7 +76,9 @@ private static Logger makeLogger() { */ private static String makeTraceId() { String traceId = MDC.get("traceId"); - validateTraceId(traceId); + if (traceId == null || traceId.trim().isEmpty()) { + return "no-trace"; + } return traceId; } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java index aab8f41..5b18dca 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -1,5 +1,6 @@ package com.likelion.backendplus4.yakplus.search.application.port.in; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; @@ -27,7 +28,7 @@ public interface SearchDrugUseCase { */ DetailSearchResponse searchByDrugId(Long drugId); - List search(SearchRequest searchRequest); + SearchResponseList searchDrugByNatural(DrugSearchNatural drugSearchNatural); AutoCompleteStringList getSymptomAutoComplete(String q); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java index 7c98e1d..6619933 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java @@ -2,13 +2,15 @@ import java.util.List; +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; import org.springframework.data.domain.Page; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; public interface DrugSearchRepositoryPort { - List searchBySymptoms(String query, float[] vector, int from, int size); + List searchByNatural(SearchByNaturalParams searchByNaturalParams); List getSymptomAutoCompleteResponse(String q); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java new file mode 100644 index 0000000..55dc847 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java @@ -0,0 +1,21 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out.dto; + +import lombok.Getter; + +@Getter +public class SearchByNaturalParams { + private final String query; + private final float[] vector; + private final int from; + private final int size; + + public SearchByNaturalParams(String query, float[] vector, int from, int size) { + if (vector == null) { + throw new IllegalArgumentException("Vector는 null일 수 없습니다."); + } + this.query = query; + this.vector = vector; + this.from = from; + this.size = size; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java new file mode 100644 index 0000000..c1e0cb0 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java @@ -0,0 +1,11 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out.mapper; + +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; + +public class SearchByNaturalParamsMapper { + public static SearchByNaturalParams toParams(DrugSearchNatural natural, float[] embeddings) { + int from = natural.getPage() * natural.getSize(); + return new SearchByNaturalParams(natural.getQuery(), embeddings, from, natural.getSize()); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index c3c7ac4..a0af9df 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -2,7 +2,10 @@ import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRdbRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.mapper.SearchByNaturalParamsMapper; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; @@ -16,6 +19,7 @@ import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; +import com.likelion.backendplus4.yakplus.search.presentation.mapper.SearchResponseMapper; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -30,7 +34,7 @@ * 사용자 검색 요청을 처리하고, 벡터 유사도 및 텍스트 검색을 통해 * 결과를 반환하는 서비스 구현체 * - * @modified 2025-04-29 + * @modified 2025-05-02 * @since 2025-04-22 */ @Service @@ -40,6 +44,57 @@ public class DrugSearcher implements SearchDrugUseCase { private final EmbeddingPort embeddingPort; private final DrugSearchRdbRepositoryPort drugSearchRdbRepositoryPort; + /** + * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 + * 리스트로 매핑하여 반환한다. + * + * @param request 검색어 및 페이지 정보 + * @return 검색 결과 DTO 리스트 + * @throws SearchException 검색어가 유효하지 않은 경우 발생 + * @author 정안식 + * @modified 2025-05-02 + * - 25.05.02 - SearchResponseList를 반환하도록 수정 + * @since 2025-04-22 + */ + @Override + public SearchResponseList searchDrugByNatural(DrugSearchNatural DrugSearchNatural) { + log("search() 메서드 호출, 검색어: " + DrugSearchNatural.getQuery()); + return searchDrugs(DrugSearchNatural, generateEmbeddings(DrugSearchNatural.getQuery())); + } + + /** + * OpenAI API를 통해 검색어의 임베딩 벡터를 생성한다. + * + * @param query 검색어 문자열 + * @return 임베딩 벡터 배열 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + private float[] generateEmbeddings(String query) { + log("generateEmbeddings() 메서드 호출, 검색어: " + query); + return embeddingPort.getEmbedding(query); + } + + /** + * 생성된 임베딩 벡터와 검색 요청 정보를 이용해 Elasticsearch에서 조회를 수행하고, + * 도메인 모델 리스트를 SearchResponse DTO 리스트로 변환해 반환한다. + * + * @param searchRequest 검색어 및 페이지/사이즈 정보가 담긴 DTO + * @param embeddings 검색어에 대한 임베딩 벡터 배열 + * @return SearchResponse 객체 리스트 + * @author 정안식 + * @modified 2025-05-02 + * - 25.05.02 - SearchResponseList를 반환하도록 수정 + * @since 2025-04-22 + */ + private SearchResponseList searchDrugs(DrugSearchNatural drugSearchNatural, float[] embeddings) { + log("searchDrugs() 메서드 호출"); + List drugs = drugSearchRepositoryPort.searchByNatural(SearchByNaturalParamsMapper.toParams(drugSearchNatural, embeddings)); + log("searchDrugs() 메서드 완료, 검색어: " + drugSearchNatural.getQuery() + ", 검색 결과 개수: " + drugs.size()); + return SearchResponseMapper.toResponseListWithCount(drugs); + } + /** * 의약품 ID를 통해 상세 정보를 조회합니다. * @@ -54,24 +109,6 @@ public DetailSearchResponse searchByDrugId(Long drugId){ Drug drug = drugSearchRdbRepositoryPort.findById(drugId); return DrugMapper.toDetailResponse(drug); } - /** - * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 - * 리스트로 매핑하여 반환한다. - * - * @param request 검색어 및 페이지 정보 - * @return 검색 결과 DTO 리스트 - * @throws SearchException 검색어가 유효하지 않은 경우 발생 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - @Override - public List search(SearchRequest request) { - log("search() 메서드 호출, 검색어: " + request.query()); - validateQuery(request.query()); - float[] embeddings = generateEmbeddings(request.query()); - return searchDrugs(request, embeddings); - } /** * 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다. @@ -151,78 +188,4 @@ public SearchResponseList searchDrugByDrugName(String q, int page, int size) { drugPage.getTotalElements() ); } - - /** - * 검색어가 null이거나 빈 문자열인지 검사하고, - * 유효하지 않으면 SearchException을 던진다. - * - * @param query 검색어 문자열 - * @throws SearchException INVALID_QUERY 코드와 함께 발생 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private void validateQuery(String query) { - log("validateQuery() 메서드 호출, 검색어: " + query); - if (query == null || query.isEmpty()) { - log(LogLevel.ERROR, "검색 쿼리가 비어있거나 null입니다."); - throw new SearchException(SearchErrorCode.INVALID_QUERY); - } - } - - /** - * OpenAI API를 통해 검색어의 임베딩 벡터를 생성한다. - * - * @param query 검색어 문자열 - * @return 임베딩 벡터 배열 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private float[] generateEmbeddings(String query) { - log("generateEmbeddings() 메서드 호출, 검색어: " + query); - return embeddingPort.getEmbedding(query); - } - - /** - * 생성된 임베딩 벡터와 검색 요청 정보를 이용해 Elasticsearch에서 조회를 수행하고, - * 도메인 모델 리스트를 SearchResponse DTO 리스트로 변환해 반환한다. - * - * @param searchRequest 검색어 및 페이지/사이즈 정보가 담긴 DTO - * @param embeddings 검색어에 대한 임베딩 벡터 배열 - * @return SearchResponse 객체 리스트 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private List searchDrugs(SearchRequest searchRequest, float[] embeddings) { - log("searchDrugs() 메서드 호출, 검색어: " + searchRequest.query()); - List drugs = drugSearchRepositoryPort.searchBySymptoms(searchRequest.query(), embeddings, - searchRequest.size(), searchRequest.page() * searchRequest.size()); - log("searchDrugs() 메서드 완료, 검색어: " + searchRequest.query() + ", 검색 결과 개수: " + drugs.size()); - return mapToDrugDomain(drugs); - } - - /** - * 도메인 모델 객체 리스트를 받아서, 각 객체의 필드를 추출한 후 - * SearchResponse DTO로 매핑하여 리스트로 반환한다. - * - * @param drugs 도메인 모델 Drug 객체 리스트 - * @return SearchResponse DTO 리스트 - * @author 정안식 - * @since 2025-04-22 - * @modified 2025-04-27 - * 25.04.27 - Drug 도메인 객체의 필드명에 맞추어 수정 - */ - private List mapToDrugDomain(List drugs) { - return drugs.stream() - .map(d -> new SearchResponse( - d.getDrugId(), - d.getDrugName(), - d.getCompany(), - d.getEfficacy(), - d.getImageUrl() - )) - .collect(Collectors.toList()); - } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java index 8dbbca7..0517396 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -3,11 +3,37 @@ import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; - +/** + * 에러 코드 인터페이스 각 에러 항목에 대한 HTTP 상태, 에러 번호, 메시지를 제공한다. + * A[BB][CCC] + * A (1자리) : 에러 심각도 (1~5) + * 1: 클라이언트 오류 + * 2: 인증 관련 오류 + * 3: 사용자 관련 오류 + * 4: 서버 오류 + * 5: 시스템 오류 + * + * BB (2자리) : 도메인 코드 + * 10: 사용자 관련 (ex: USER_NOT_FOUND) + * 20: 인증 관련 (ex: AUTHORIZATION_FAILED) + * 30: DB 관련 오류 (ex: DB_CONNECTION_FAILED) + * 40: API 관련 오류 (ex: API_TIMEOUT) + * 50: 시스템 오류 (ex: INTERNAL_SERVER_ERROR) + * + * CCC (3자리) : 세부 오류 순번 + * 001: 첫 번째 오류 + * 002: 두 번째 오류 + * 003: 세 번째 오류, 등등 + * + * @modified 2025-04-18 + * @since 2025-04-16 + */ @RequiredArgsConstructor public enum SearchErrorCode implements ErrorCode { INVALID_QUERY(HttpStatus.BAD_REQUEST, 140001, "검색어를 입력해주세요"), - INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 140002, "지원하지 않는 검색 타입입니다."), + INVALID_PAGE(HttpStatus.BAD_REQUEST, 140002, "페이지 번호는 0 이상이어야 합니다."), + INVALID_SIZE(HttpStatus.BAD_REQUEST, 140003, "페이지 크기는 1 이상이어야 합니다."), + INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 140004, "지원하지 않는 검색 타입입니다."), ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), RDB_SEARCH_ERROR(HttpStatus.NO_CONTENT, 430003, "검색 결과가 없습니다"), EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"), diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java new file mode 100644 index 0000000..f32e699 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java @@ -0,0 +1,19 @@ +package com.likelion.backendplus4.yakplus.search.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@Builder +public class DrugSearchDomainNatural { + private Long drugId; + private String drugName; + private List efficacy; + private String company; + private String imageUrl; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java new file mode 100644 index 0000000..5f73d82 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java @@ -0,0 +1,34 @@ +package com.likelion.backendplus4.yakplus.search.domain.model; + +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; +import lombok.Builder; +import lombok.Getter; +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; +@Getter +@Builder +public class DrugSearchNatural { + private final String query; + private final int page; + private final int size; + + public DrugSearchNatural(String query, int page, int size) { + + if (query == null || query.isBlank()) { + log(LogLevel.ERROR, "쿼리가 null이거나 비어있습니다."); + throw new SearchException(SearchErrorCode.INVALID_QUERY); + } + if (page < 0) { + log(LogLevel.ERROR, "페이지 번호가 음수입니다."); + throw new SearchException(SearchErrorCode.INVALID_PAGE); + } + if (size <= 0) { + log(LogLevel.ERROR, "사이즈가 0 이하입니다."); + throw new SearchException(SearchErrorCode.INVALID_SIZE); + } + this.query = query; + this.page = page; + this.size = size; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index ce2d671..58ac34c 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -1,24 +1,25 @@ package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; +import co.elastic.clients.elasticsearch.core.search.Hit; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; -import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; -import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; - -import co.elastic.clients.elasticsearch.core.SearchResponse; -import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption; -import co.elastic.clients.elasticsearch.core.search.Hit; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.natural.DrugSearchNaturalMapper; import lombok.RequiredArgsConstructor; import org.apache.http.entity.ContentType; import org.apache.http.nio.entity.NStringEntity; -import co.elastic.clients.elasticsearch.ElasticsearchClient; import org.elasticsearch.client.Request; import org.elasticsearch.client.Response; import org.elasticsearch.client.RestClient; @@ -29,13 +30,13 @@ import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; -import java.util.Objects; - /** * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. @@ -70,19 +71,119 @@ public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { * @since 2025-04-22 */ @Override - public List searchBySymptoms(String query, float[] vector, int size, int from) { + public List searchByNatural(SearchByNaturalParams params) { try { - log("searchBySymptoms() 메서드 호출, 검색어: " + query); - String esQuery = buildSearchQuery(query, vector, size, from); + log("searchBySymptoms() 메서드 호출, 검색어: " + params.getQuery()); + String esQuery = buildSearchQuery( + params.getQuery(), + params.getVector(), + params.getSize(), + params.getFrom() + ); Response response = executeSearch(esQuery); return parseSearchResults(response); } catch (Exception e) { - log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + query, e); + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + params.getQuery(), e); throw new SearchException(SearchErrorCode.ES_SEARCH_ERROR); } } + /** + * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. + * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. + * + * @param query 검색어 + * @param vector 검색어 임베딩 벡터 + * @param size 한 페이지에 조회할 문서 수 + * @param from 조회 시작 오프셋 + * @return Elasticsearch에 전달할 쿼리 JSON 문자열 + * @throws IOException JSON 직렬화 과정에서 오류 발생 시 + * @author 정안식 + * @modified 2025-04-27 + * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 + * - 텍스트 매칭 필드 searchAll → efficacy 로 변경(현재는 약의 효과로만 검색하고 있음) + * @since 2025-04-22 + */ + private String buildSearchQuery(String query, float[] vector, int size, int from) throws IOException { + String vectorJson = objectMapper.writeValueAsString(vector); + String escapedQuery = query.replace("\"", "\\\\\""); + return """ + { + "from": %d, + "size": %d, + "query": { + "bool": { + "must": { + "script_score": { + "query": { "match_all": {} }, + "script": { + "inline": "cosineSimilarity(params.queryVector, 'vector') + 1.0", + "lang": "painless", + "params": { "queryVector": %s } + } + } + }, + "should": [ + { + "match": { + "efficacy": { + "query": "%s", + "fuzziness": "AUTO", + "boost": 0.2 + } + } + } + ] + } + } + } + """.formatted(from, size, vectorJson, escapedQuery); + } + + /** + * Elasticsearch에 빌드된 쿼리를 전송하여 검색 결과 Response를 반환한다. + * + * @param esQuery Elasticsearch Query DSL JSON 문자열 + * @return Elasticsearch 응답 객체 + * @throws IOException 요청 전송 또는 응답 처리 중 오류 발생 시 + * @author 정안식 + * @modified 2025-04-24 + * @since 2025-04-22 + */ + private Response executeSearch(String esQuery) throws IOException { + log("executeSearch() 메서드 호출"); + Request request = new Request("GET", "/" + SEARCH_INDEX + "/_search"); + request.setEntity(new NStringEntity(esQuery, ContentType.APPLICATION_JSON)); + return restClient.performRequest(request); + } + + /** + * Elasticsearch 검색 결과 Response에서 hits 배열을 파싱해 + * Drug 도메인 객체 리스트로 변환한다. + * + * @param response Elasticsearch 검색 응답 객체 + * @return 파싱된 Drug 도메인 객체 리스트 + * @throws IOException 응답 스트림 처리 중 오류 발생 시 + * @author 정안식 + * @modified 2025-05-02 + * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 + * 25.05.02 - 기존 로직(단순 순환 및 List 매칭) StreamSupport을 사용하도록 개선 + * @since 2025-04-22 + */ + private List parseSearchResults(Response response) throws IOException { + log("parseSearchResults() 메서드 호출"); + try (InputStream is = response.getEntity().getContent()) { + JsonNode hits = objectMapper.readTree(is) + .path("hits") + .path("hits"); + return StreamSupport.stream(hits.spliterator(), false) + .map(hit -> hit.path("_source")) + .map(DrugSearchNaturalMapper::toDomainNatural) + .collect(Collectors.toList()); + } + } + /** * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 * symptomSuggester 필드에서 자동완성 추천 단어 리스트를 반환합니다. @@ -91,27 +192,27 @@ public List searchBySymptoms(String query, float[] vector, int size, int f * @return 자동완성 추천 키워드 리스트 * @throws SearchException 자동완성 API 호출 실패 시 발생 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-04-28 - * */ + * @since 2025-04-24 + */ @Override public List getSymptomAutoCompleteResponse(String q) { log("getSymptomAutoCompleteResponse() 메서드 호출, 검색어: " + q); SearchResponse resp; try { resp = esClient.search(s -> s - .index("eedoc") - .suggest(su -> su - .suggesters("symp_sugg", sg -> sg - .prefix(q) - .completion(c -> c - .field("symptomSuggester") - .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 - .size(10) + .index("eedoc") + .suggest(su -> su + .suggesters("symp_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("symptomSuggester") + .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 + .size(10) + ) + ) ) - ) - ) - , Void.class); + , Void.class); } catch (IOException e) { log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); @@ -119,10 +220,10 @@ public List getSymptomAutoCompleteResponse(String q) { // Suggest 파싱 return resp.suggest().get("symp_sugg") - .get(0).completion().options().stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); + .get(0).completion().options().stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); } /** @@ -132,38 +233,38 @@ public List getSymptomAutoCompleteResponse(String q) { * @param page 조회할 페이지 번호 (0부터 시작) * @param size 페이지 당 문서 수 * @return 증상 문서 리스트 + * @throws SearchException 검색 중 오류 발생 시 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-04-28 - * @throws SearchException 검색 중 오류 발생 시 + * @since 2025-04-24 */ public Page searchDocsBySymptom(String q, int page, int size) { log("searchDocsBySymptom() 메서드 호출, 검색어: " + q); try { SearchResponse resp = esClient.search(s -> s - .index("eedoc") - .from(page * size) - .size(size) - .query(qb -> qb - .match(m -> m - .field("efficacy") // only_nouns analyzer 적용된 필드 - .query(q) // 사용자가 입력한 q 값 - ) - ) - , DrugSymptomDocument.class); + .index("eedoc") + .from(page * size) + .size(size) + .query(qb -> qb + .match(m -> m + .field("efficacy") // only_nouns analyzer 적용된 필드 + .query(q) // 사용자가 입력한 q 값 + ) + ) + , DrugSymptomDocument.class); List results = resp.hits().hits().stream() - .map(Hit::source) - .filter(Objects::nonNull) - .map(DrugMapper::toDomainBySymptomDocument) - .toList(); + .map(Hit::source) + .filter(Objects::nonNull) + .map(DrugMapper::toDomainBySymptomDocument) + .toList(); long totalHits = resp.hits().total().value(); return new PageImpl<>( - results, - PageRequest.of(page, size), - totalHits + results, + PageRequest.of(page, size), + totalHits ); } catch (IOException e) { log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); @@ -179,32 +280,32 @@ public Page searchDocsBySymptom(String q, int page, int size) * @return 자동완성 추천 키워드 리스트 * @throws SearchException 자동완성 API 호출 실패 시 발생 * @author 박찬병 - * @since 2025-04-28 * @modified 2025-04-29 + * @since 2025-04-28 */ @Override public List getDrugNameAutoCompleteResponse(String q) { log("getDrugNameAutoCompleteResponse() 메서드 호출, 검색어: " + q); try { SearchResponse resp = esClient.search(s -> s - .index("drug_name") - .suggest(su -> su - .suggesters("name_sugg", sg -> sg - .prefix(q) - .completion(c -> c - .field("drugNameSuggester") - .analyzer("drugName_autocomplete") - .size(20) - ) - ) - ), - Void.class + .index("drug_name") + .suggest(su -> su + .suggesters("name_sugg", sg -> sg + .prefix(q) + .completion(c -> c + .field("drugNameSuggester") + .analyzer("drugName_autocomplete") + .size(20) + ) + ) + ), + Void.class ); var options = resp.suggest().get("name_sugg").get(0).completion().options(); return options.stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); + .map(CompletionSuggestOption::text) + .distinct() + .toList(); } catch (IOException e) { throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); } @@ -212,7 +313,7 @@ public List getDrugNameAutoCompleteResponse(String q) { /** * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. - * + *

* matchPhrasePrefix 쿼리를 사용하여 약품명 필드에서 접두어 기반 검색을 수행합니다. * * @param q 검색어 프리픽스 (사용자 입력) @@ -220,165 +321,52 @@ public List getDrugNameAutoCompleteResponse(String q) { * @param size 페이지 당 조회할 문서 수 * @return 검색된 약품명 문서 리스트를 담은 Page 객체 (DrugSearchDomain) * @throws SearchException 검색 중 오류 발생 시 - * @since 2025-04-28 * @modified 2025-04-29 + * @since 2025-04-28 */ @Override public Page searchDocsByDrugName(String q, int page, int size) { log("searchDocsByItemName() called, query: " + q); try { SearchResponse resp = esClient.search(s -> s - .index("drug_name") - .from(page * size) - .size(size) - .query(qb -> qb - .matchPhrasePrefix(mpp -> mpp - .field("drugName") - .query(q) - ) - ), - DrugNameDocument.class + .index("drug_name") + .from(page * size) + .size(size) + .query(qb -> qb + .matchPhrasePrefix(mpp -> mpp + .field("drugName") + .query(q) + ) + ), + DrugNameDocument.class ); List results = resp.hits().hits().stream() - .map(Hit::source) - .filter(Objects::nonNull) - .map(DrugMapper::toDomainByNameDocument) - .toList(); + .map(Hit::source) + .filter(Objects::nonNull) + .map(DrugMapper::toDomainByNameDocument) + .toList(); long totalHits = resp.hits().total().value(); return new PageImpl<>( - results, - PageRequest.of(page, size), - totalHits + results, + PageRequest.of(page, size), + totalHits ); } catch (IOException e) { throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); } } - /** - * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. - * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. - * - * @param query 검색어 - * @param vector 검색어 임베딩 벡터 - * @param size 한 페이지에 조회할 문서 수 - * @param from 조회 시작 오프셋 - * @return Elasticsearch에 전달할 쿼리 JSON 문자열 - * @throws IOException JSON 직렬화 과정에서 오류 발생 시 - * @author 정안식 - * @modified 2025-04-27 - * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 - * - 텍스트 매칭 필드 searchAll → efficacy 로 변경(현재는 약의 효과로만 검색하고 있음) - * @since 2025-04-22 - */ - private String buildSearchQuery(String query, float[] vector, int size, int from) throws IOException { - String vectorJson = objectMapper.writeValueAsString(vector); - String escapedQuery = query.replace("\"", "\\\\\""); - return """ - { - "from": %d, - "size": %d, - "query": { - "bool": { - "must": { - "script_score": { - "query": { "match_all": {} }, - "script": { - "inline": "cosineSimilarity(params.queryVector, 'vector') + 1.0", - "lang": "painless", - "params": { "queryVector": %s } - } - } - }, - "should": [ - { - "match": { - "efficacy": { - "query": "%s", - "fuzziness": "AUTO", - "boost": 0.2 - } - } - } - ] - } - } - } - """.formatted(from, size, vectorJson, escapedQuery); - } - - /** - * Elasticsearch에 빌드된 쿼리를 전송하여 검색 결과 Response를 반환한다. - * - * @param esQuery Elasticsearch Query DSL JSON 문자열 - * @return Elasticsearch 응답 객체 - * @throws IOException 요청 전송 또는 응답 처리 중 오류 발생 시 - * @author 정안식 - * @modified 2025-04-24 - * @since 2025-04-22 - */ - private Response executeSearch(String esQuery) throws IOException { - log("executeSearch() 메서드 호출"); - Request request = new Request("GET", "/" + SEARCH_INDEX + "/_search"); - request.setEntity(new NStringEntity(esQuery, ContentType.APPLICATION_JSON)); - return restClient.performRequest(request); - } - - /** - * Elasticsearch 검색 결과 Response에서 hits 배열을 파싱해 - * Drug 도메인 객체 리스트로 변환한다. - * - * @param response Elasticsearch 검색 응답 객체 - * @return 파싱된 Drug 도메인 객체 리스트 - * @throws IOException 응답 스트림 처리 중 오류 발생 시 - * @author 정안식 - * @modified 2025-04-27 - * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 - * @since 2025-04-22 - */ - private List parseSearchResults(Response response) throws IOException { - log("parseSearchResults() 메서드 호출"); - InputStream is = response.getEntity().getContent(); - JsonNode hits = objectMapper.readTree(is).path("hits").path("hits"); - - List results = new ArrayList<>(); - for (JsonNode hit : hits) { - JsonNode src = hit.path("_source"); - - long drugId = src.path("drugId").asLong(); - String drugName = src.path("drugName").asText(); - String company = src.path("company").asText(); - - List efficacyList = new ArrayList<>(); - for (JsonNode e : src.path("efficacy")) { - efficacyList.add(e.asText()); - } - - String imageUrl = src.path("imageUrl").asText(null); - float[] vectorArr = parseVector(src.path("vector")); - - Drug drug = Drug.builder() - .drugId(drugId) - .drugName(drugName) - .company(company) - .efficacy(efficacyList) - .imageUrl(imageUrl) - .vector(vectorArr) - .build(); - results.add(drug); - } - return results; - } - /** * JSON 배열로 전달된 vector 노드를 float[]로 변환한다. * * @param vectorNode vectors JSON 배열 노드 * @return float[] 변환된 벡터 배열 * @author 정안식 + * @modified 2025-05-02 + * 25.05.02 - 사용하지 않도록 수정 * @since 2025-04-27 */ private float[] parseVector(JsonNode vectorNode) { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java new file mode 100644 index 0000000..05cc602 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java @@ -0,0 +1,28 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.support.natural; + +import com.fasterxml.jackson.databind.JsonNode; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class DrugSearchNaturalMapper { + public static DrugSearchDomainNatural toDomainNatural(JsonNode src) { + long id = src.path("drugId").asLong(); + String name = src.path("drugName").asText(); + String company = src.path("company").asText(); + List efficacy = StreamSupport.stream(src.path("efficacy").spliterator(), false) + .map(JsonNode::asText) + .collect(Collectors.toList()); + String imageUrl = src.path("imageUrl").asText(null); + + return DrugSearchDomainNatural.builder() + .drugId(id) + .drugName(name) + .company(company) + .efficacy(efficacy) + .imageUrl(imageUrl) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java new file mode 100644 index 0000000..0227f35 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java @@ -0,0 +1,14 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.support.natural; + +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; + +public class SearchNaturalMapper { + + public static DrugSearchNatural toDrugSearchNatural(String query, int page, int size) { + return DrugSearchNatural.builder() + .query(query) + .page(page) + .size(size) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 33bbc0e..201f279 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -1,26 +1,16 @@ package com.likelion.backendplus4.yakplus.search.presentation.controller; -import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; -import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.response.ApiResponse; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.natural.SearchNaturalMapper; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; - import lombok.RequiredArgsConstructor; 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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; +import org.springframework.web.bind.annotation.*; import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; @@ -31,43 +21,48 @@ * @since 2025-04-22 */ @RestController -@RequestMapping("/api/drugs") +@RequestMapping("/drugs") @RequiredArgsConstructor public class DrugController { private final SearchDrugUseCase searchDrugUseCase; + /** * 약품 검색 요청을 처리하여 검색 결과를 반환한다. * - * @param searchRequest 검색어, 페이지 및 페이지 크기를 담은 요청 객체 + * @param SearchRequest 검색어, 페이지 및 페이지 크기를 담은 요청 객체 * @return 검색 결과 리스트를 포함한 표준 응답 구조 * @author 정안식 * @modified 2025-04-24 * @since 2025-04-22 */ - @PostMapping("/search") - public ResponseEntity>> search(@RequestBody SearchRequest searchRequest) { - log("drugController 요청 수신" + searchRequest.toString()); - return ApiResponse.success(searchDrugUseCase.search(searchRequest)); + @GetMapping("/search/natural") + public ResponseEntity> searchNatural( + @RequestParam("q") String query, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "10") int size) { + + log("drugController 자연어 검색 요청 수신 - 자연어: " + query); + DrugSearchNatural natural = SearchNaturalMapper.toDrugSearchNatural(query, page, size); + return ApiResponse.success(searchDrugUseCase.searchDrugByNatural(natural)); } /** * 사용자 입력 키워드를 바탕으로 자동완성 추천 결과를 조회합니다. - * + *

* 검색 타입(type)에 따라 증상 자동완성 또는 약품명 자동완성 결과를 반환합니다. * * @param type 자동완성 타입 (symptom 또는 name) * @param q 검색어 프리픽스 * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse * @throws SearchException 지원하지 않는 타입 입력 시 예외 발생 - * * @author 박찬병 - * @since 2025-04-24 * @modified 2025-04-29 + * @since 2025-04-24 */ @GetMapping("/autocomplete/{type}") public ResponseEntity> autocomplete( - @PathVariable String type, - @RequestParam String q) { + @PathVariable String type, + @RequestParam String q) { log("drugController 요청 수신 - type: " + type + ", query: " + q); @@ -84,22 +79,22 @@ public ResponseEntity> autocomplete( /** * 증상 또는 약품명 검색을 통해 매칭되는 약품 리스트를 조회합니다. * - * @param type 검색 타입 (symptom 또는 name) - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 (기본값 0) - * @param size 페이지 당 문서 수 (기본값 10) + * @param type 검색 타입 (symptom 또는 name) + * @param q 검색어 프리픽스 + * @param page 조회할 페이지 번호 (기본값 0) + * @param size 페이지 당 문서 수 (기본값 10) * @return 검색 결과를 담은 ApiResponse * @throws SearchException 지원하지 않는 검색 타입 입력 시 예외 발생 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-04-29 + * @since 2025-04-24 */ @GetMapping("/search/{type}") public ResponseEntity> searchDrugs( - @PathVariable String type, - @RequestParam String q, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { + @PathVariable String type, + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { log("drugController 요청 수신 - type: " + type + ", query: " + q); @@ -118,12 +113,11 @@ public ResponseEntity> searchDrugs( * * @param id 조회할 의약품의 고유 ID * @return 의약품 상세 정보가 담긴 ApiResponse - * * @author 함예정 * @since 2025-04-30 */ @GetMapping("/search/detail/{id}") - public ResponseEntity> searchDetail(@PathVariable Long id){ + public ResponseEntity> searchDetail(@PathVariable Long id) { return ApiResponse.success(searchDrugUseCase.searchByDrugId(id)); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchRequestMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchRequestMapper.java new file mode 100644 index 0000000..c315850 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchRequestMapper.java @@ -0,0 +1,24 @@ +package com.likelion.backendplus4.yakplus.search.presentation.mapper; + +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Presentation 계층의 Request 매핑 로직을 담당하는 클래스입니다. + * HTTP 요청 파라미터를 SearchRequest 도메인 객체로 변환합니다. + * + * @since 2025-05-02 + */ +public class SearchRequestMapper { + /** + * HTTP GET/POST 요청 파라미터를 SearchRequest DTO로 매핑합니다. + * + * @param query 검색어 + * @param page 페이지 번호 (0부터) + * @param size 페이지 사이즈 + * @return SearchRequest 객체 + */ + public static SearchRequest toSearchRequest(String query, int page, int size) { + return new SearchRequest(query, page, size); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java new file mode 100644 index 0000000..2be2231 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java @@ -0,0 +1,33 @@ +package com.likelion.backendplus4.yakplus.search.presentation.mapper; + +import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; + +import java.util.List; +import java.util.stream.Collectors; + +public class SearchResponseMapper { + + public static SearchResponse toResponse(DrugSearchDomainNatural dsn) { + return SearchResponse.builder() + .drugId(dsn.getDrugId()) + .drugName(dsn.getDrugName()) + .company(dsn.getCompany()) + .efficacy(dsn.getEfficacy()) + .imageUrl(dsn.getImageUrl()) + .build(); + } + + public static List toResponseList(List drugs) { + return drugs.stream() + .map(SearchResponseMapper::toResponse) + .collect(Collectors.toList()); + } + + public static SearchResponseList toResponseListWithCount(List drugs) { + List responses = toResponseList(drugs); + return new SearchResponseList(responses, responses.size()); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java new file mode 100644 index 0000000..8749196 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java @@ -0,0 +1,28 @@ +package com.likelion.backendplus4.yakplus.switcher.application; + +import com.likelion.backendplus4.yakplus.switcher.application.port.in.EmbeddingRoutingUseCase; +import com.likelion.backendplus4.yakplus.switcher.application.port.out.EmbeddingSwitchPort; +import org.springframework.stereotype.Service; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +@Service +public class EmbeddingRouter implements EmbeddingRoutingUseCase { + private final EmbeddingSwitchPort switchPort; + + public EmbeddingRouter(EmbeddingSwitchPort switchPort) { + this.switchPort = switchPort; + } + + @Override + public void switchEmbedding(String adapterBeanName) { + log("임베딩 스위치 요청 수신 - 어댑터명: " + adapterBeanName); + switchPort.switchTo(adapterBeanName); + } + + @Override + public String getAdapterBeanName() { + log("현재 선택된 어댑터 빈 이름 요청"); + return switchPort.getAdapterBeanName(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java new file mode 100644 index 0000000..0e01bf3 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java @@ -0,0 +1,6 @@ +package com.likelion.backendplus4.yakplus.switcher.application.port.in; + +public interface EmbeddingRoutingUseCase { + void switchEmbedding(String adapterBeanName); + String getAdapterBeanName(); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java new file mode 100644 index 0000000..75b74bc --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java @@ -0,0 +1,6 @@ +package com.likelion.backendplus4.yakplus.switcher.application.port.out; + +public interface EmbeddingSwitchPort { + void switchTo(String adapterBeanName); + String getAdapterBeanName(); +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java new file mode 100644 index 0000000..037a019 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java @@ -0,0 +1,60 @@ +package com.likelion.backendplus4.yakplus.switcher.infrastructure.route.adapter; + +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.switcher.application.port.out.EmbeddingSwitchPort; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +@Component("embeddingRouterAdapter") +@Primary +public class EmbeddingRouterAdapter implements EmbeddingPort, EmbeddingSwitchPort { + private static final String DEFAULT_ADAPTER = "openAIEmbeddingAdapter"; + private final Map adapters; + private volatile EmbeddingPort embeddingPort; + private volatile String adapterBeanName; + + public EmbeddingRouterAdapter(Map allAdapters) { + this.adapters = allAdapters; + log("구현체 목록: " + adapters.keySet()); + } + + @PostConstruct + public void init() { + log("EmbeddingRouterAdapter 초기화 - 어댑터명: " + DEFAULT_ADAPTER); + switchTo(DEFAULT_ADAPTER); + } + + @Override + public float[] getEmbedding(String text) { + if (embeddingPort == null) { + log(LogLevel.ERROR, "임베딩 어댑터가 선택되지 않았습니다."); + throw new IllegalStateException("No adapter selected"); + } + return embeddingPort.getEmbedding(text); + } + + @Override + public void switchTo(String adapterBeanName) { + log("어댑터 스위치 시도 - 어댑터명: " + adapterBeanName); + EmbeddingPort target = adapters.get(adapterBeanName); + if (target == null) { + log(LogLevel.ERROR, "어댑터 빈을 찾을 수 없습니다: " + adapterBeanName); + throw new IllegalArgumentException("Unknown adapter: " + adapterBeanName); + } + this.embeddingPort = target; + this.adapterBeanName = adapterBeanName; + log("어댑터 스위치 완료 - 현재 어댑터: " + adapterBeanName); + } + + @Override + public String getAdapterBeanName() { + log("어댑터 빈 이름 요청 - 현재 선택된 어댑터: " + adapterBeanName); + return adapterBeanName; + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java new file mode 100644 index 0000000..825b657 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java @@ -0,0 +1,30 @@ +package com.likelion.backendplus4.yakplus.switcher.presentation.controller; + +import com.likelion.backendplus4.yakplus.response.ApiResponse; +import com.likelion.backendplus4.yakplus.switcher.application.port.in.EmbeddingRoutingUseCase; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +@RestController +@RequestMapping("/switch/embeddings") +public class EmbeddingRouterController { + private final EmbeddingRoutingUseCase routerUseCase; + + public EmbeddingRouterController(EmbeddingRoutingUseCase routerUseCase) { + this.routerUseCase = routerUseCase; + } + + @PostMapping("/switch/{adapterBeanName}") + public ResponseEntity> switchAdapter(@PathVariable String adapterBeanName) { + log("스위치 대상 인덱스명 : " + adapterBeanName); + routerUseCase.switchEmbedding(adapterBeanName); + return ApiResponse.success("어댑터 변경됨 - 어댑터명: " + adapterBeanName); + } + + @GetMapping("/current/adapter") + public ResponseEntity> checkCurrentAdapter() { + return ApiResponse.success(routerUseCase.getAdapterBeanName()); + } +} From 2fe63f04bcb1248e787da7c8d56fd13d86054e5f Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Mon, 5 May 2025 16:36:22 +0900 Subject: [PATCH 22/25] =?UTF-8?q?=E2=9C=A8=20=20Feature:=20#62=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=ED=95=A9=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ Refactor: 자연어 검색 로직을 리팩토링 하였습니다.(헥사고날 구조 적용 및 내부 리팩토링), 스위칭 로직을 도입하였습니다. * ✨ Feature: yakplus-batch의 코드 통합 초안 * 🐛 Fix: 변수 선언 안된 문제 수정 * 🐛 Fix: 임포트 문제 수정 * 🐛 Fix: 컴파일 오류 수정 * 📦 Chore: 불필요한 import 삭제 * 📦 Chore: Swagger 의존성 및 설정 추가 * ✨ Feat: 컨트롤러 Swagger 적용 * ♻️ Refactor: 인덱스 이름 상수로 변경 및 매퍼 분리 --------- Co-authored-by: leelise Co-authored-by: pcb7893@naver.com <122460524+chanbyoung@users.noreply.github.com> --- build.gradle | 3 + .../yakplus/YakplusApplication.java | 9 +- .../common/configuration/LogbackConfig.java | 327 +++++++-------- .../common/configuration/SwaggerConfig.java | 21 + .../handler/GlobalExceptionHandler.java | 150 ++++--- .../yakplus/common/util/log/LogLevel.java | 24 +- .../common/util/log/LoggerWithTraceId.java | 18 - .../service/DrugApprovalDetailScraper.java | 9 - .../DrugApprovalDetailScraperImpl.java | 179 -------- .../application/service/DrugDataService.java | 9 - .../service/DrugDataServiceImpl.java | 29 -- .../service/DrugImageGovScraper.java | 48 --- .../yakplus/drug/domain/model/GovDrug.java | 102 ----- .../drug/domain/model/GovDrugDetail.java | 35 -- .../drug/domain/model/vo/MaterialInfo.java | 8 - .../drug/domain/model/vo/WarningType.java | 45 --- .../drug/exception/ScraperException.java | 18 - .../exception/error/ScraperErrorCode.java | 34 -- .../entity/ApiDataDrugImgEntity.java | 20 - .../entity/GovDrugDetailEntity.java | 89 ---- .../jdbc/GovDrugJdbcRepository.java | 40 -- .../repository/jdbc/JdbcBatchSetter.java | 48 --- .../repository/jpa/ApiDataDrugImgRepo.java | 10 - .../jpa/GovDrugDetailJpaRepository.java | 18 - .../config/ApiRestTemplateConfig.java | 19 - .../support/api/ApiResponseMapper.java | 42 -- .../support/api/ApiUriCompBuilder.java | 82 ---- .../support/mapper/DrugDataMapper.java | 40 -- .../support/parser/MaterialParser.java | 59 --- .../support/parser/XMLParser.java | 217 ---------- .../controller/DrugDataTestController.java | 25 -- .../controller/DrugDetailController.java | 34 -- .../controller/DrugImageController.java | 19 - .../yakplus/logtest/MyController.java | 48 --- .../yakplus/logtest/MyService.java | 54 --- .../port/in/SearchDrugUseCase.java | 87 +++- .../port/out/DrugSearchRdbRepositoryPort.java | 25 +- .../port/out/DrugSearchRepositoryPort.java | 95 ++++- .../application/port/out/EmbeddingPort.java | 10 + .../port/out/dto/SearchByKeywordParams.java | 14 + .../port/out/dto/SearchByNaturalParams.java | 18 + .../mapper/SearchByKeywordParamsMapper.java | 15 + .../mapper/SearchByNaturalParamsMapper.java | 19 + .../application/service/DrugSearcher.java | 110 +++-- .../exception/error/SearchErrorCode.java | 6 +- .../search/config/ElasticsearchConfig.java | 1 - .../search/config/RestTemplateConfig.java | 13 + .../yakplus/search/domain/model/Drug.java | 1 - .../search/domain/model/DrugSearchDomain.java | 6 +- .../domain/model/DrugSearchDomainNatural.java | 5 + .../domain/model/DrugSearchNatural.java | 20 + .../vo => search/domain/model}/Material.java | 2 +- .../adapter/persistence/DrugJpaAdapter.java | 107 +++-- .../persistence/ElasticsearchDrugAdapter.java | 382 ++++++++++-------- .../persistence/KmBertEmbeddingAdapter.java | 41 ++ .../persistence/KrSBertEmbeddinggAdapter.java | 40 ++ .../persistence/OpenAIEmbeddingAdapter.java | 1 - .../document/DrugKeywordDocument.java | 60 +++ .../document/DrugNameDocument.java | 48 --- .../document/DrugSymptomDocument.java | 48 --- .../persistence/dto/EmbeddingRequestText.java | 12 + .../{GovDrugEntity.java => DrugEntity.java} | 2 +- ...Repository.java => DrugJpaRepository.java} | 4 +- .../infrastructure/support/DrugMapper.java | 64 +-- .../support/UriCompBuilder.java | 65 +++ .../natural/DrugSearchNaturalMapper.java | 34 +- .../support/natural/SearchNaturalMapper.java | 19 + .../controller/DrugController.java | 144 ++++--- .../presentation/controller/SearchType.java | 17 - .../controller/docs/DrugControllerDocs.java | 68 ++++ .../dto/response/DetailSearchResponse.java | 27 +- .../mapper/SearchResponseMapper.java | 61 ++- .../switcher/application/EmbeddingRouter.java | 22 + .../port/in/EmbeddingRoutingUseCase.java | 16 + .../port/out/EmbeddingSwitchPort.java | 17 + .../route/adapter/EmbeddingRouterAdapter.java | 47 ++- .../controller/EmbeddingRouterController.java | 22 + .../docs/EmbeddingRouterControllerDocs.java | 40 ++ src/main/resources/application.yml | 13 + 79 files changed, 1689 insertions(+), 2111 deletions(-) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/common/configuration/SwaggerConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByKeywordParams.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByKeywordParamsMapper.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/config/RestTemplateConfig.java rename src/main/java/com/likelion/backendplus4/yakplus/{drug/domain/model/vo => search/domain/model}/Material.java (88%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KmBertEmbeddingAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugKeywordDocument.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/dto/EmbeddingRequestText.java rename src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/{GovDrugEntity.java => DrugEntity.java} (98%) rename src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/{GovDrugJpaRepository.java => DrugJpaRepository.java} (72%) create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/UriCompBuilder.java delete mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/docs/DrugControllerDocs.java create mode 100644 src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/docs/EmbeddingRouterControllerDocs.java diff --git a/build.gradle b/build.gradle index 5eca490..f806f2d 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,9 @@ dependencies { //ai implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M5' + // Swagger 설치 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // build.gradle if (project.hasProperty('env') && project.env == 'test') { dependencies { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java b/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java index 0e8d32a..46dc920 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/YakplusApplication.java @@ -1,14 +1,11 @@ package com.likelion.backendplus4.yakplus; -import com.likelion.backendplus4.yakplus.common.configuration.LogbackConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class YakplusApplication { - public static void main(String[] args) { - LogbackConfig logbackConfig = new LogbackConfig(); - logbackConfig.configure(); - SpringApplication.run(YakplusApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(YakplusApplication.class, args); + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java index 5aaa270..492bd32 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/LogbackConfig.java @@ -1,5 +1,14 @@ package com.likelion.backendplus4.yakplus.common.configuration; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.encoder.PatternLayoutEncoder; @@ -9,171 +18,171 @@ import ch.qos.logback.core.rolling.TimeBasedRollingPolicy; import ch.qos.logback.core.util.FileSize; import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Configuration; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; /** * 로깅 설정을 위한 설정 클래스 - * + * * @modified 2025-04-18 * @since 2025-04-16 */ @Configuration public class LogbackConfig { - private static final String LOG_DIRECTORY = "logs"; - private static final String LOG_FILE_NAME = "like-lion.log"; - private static final String LOG_PATTERN = "%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n"; - private static final int MAX_HISTORY = 30; - private static final String TOTAL_SIZE_CAP = "1GB"; - - /** - * 로깅 설정을 초기화하는 메서드 - * - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - @PostConstruct - public void configure() { - LoggerContext context = initializeLoggerContext(); - createLogDirectory(); - - ConsoleAppender consoleAppender = createConsoleAppender(context); - FileAppender fileAppender = createFileAppender(context); - - configureRootLogger(context, consoleAppender, fileAppender); - } - - /** - * LoggerContext를 초기화하는 메서드 - * - * @return LoggerContext 초기화된 로거 컨텍스트 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private LoggerContext initializeLoggerContext() { - LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); - context.reset(); - return context; - } - - /** - * 로그 디렉토리를 생성하는 메서드 - * - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private void createLogDirectory() { - Path logPath = Paths.get(LOG_DIRECTORY); - try { - if (!Files.exists(logPath)) { - Files.createDirectories(logPath); - } - } catch (Exception e) { - throw new RuntimeException("로그 디렉토리 생성 실패", e); - } - } - - /** - * 콘솔 어펜더를 생성하는 메서드 - * - * @param context LoggerContext 로거 컨텍스트 - * @return ConsoleAppender 생성된 콘솔 어펜더 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private ConsoleAppender createConsoleAppender(LoggerContext context) { - ConsoleAppender appender = new ConsoleAppender<>(); - appender.setContext(context); - appender.setEncoder(createEncoder(context)); - appender.start(); - return appender; - } - - /** - * 파일 어펜더를 생성하는 메서드 - * - * @param context LoggerContext 로거 컨텍스트 - * @return FileAppender 생성된 파일 어펜더 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private FileAppender createFileAppender(LoggerContext context) { - FileAppender appender = new FileAppender<>(); - appender.setContext(context); - appender.setFile(LOG_DIRECTORY + "/" + LOG_FILE_NAME); - appender.setAppend(true); - appender.setEncoder(createEncoder(context)); - - TimeBasedRollingPolicy rollingPolicy = createRollingPolicy(context, appender); - rollingPolicy.start(); - - appender.start(); - return appender; - } - - /** - * 패턴 레이아웃 인코더를 생성하는 메서드 - * - * @param context LoggerContext 로거 컨텍스트 - * @return PatternLayoutEncoder 생성된 패턴 레이아웃 인코더 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private PatternLayoutEncoder createEncoder(LoggerContext context) { - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(context); - encoder.setPattern(LOG_PATTERN); - encoder.start(); - return encoder; - } - - /** - * 롤링 정책을 생성하는 메서드 - * - * @param context LoggerContext 로거 컨텍스트 - * @param parent FileAppender 부모 파일 어펜더 - * @return TimeBasedRollingPolicy 생성된 롤링 정책 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private TimeBasedRollingPolicy createRollingPolicy(LoggerContext context, FileAppender parent) { - TimeBasedRollingPolicy policy = new TimeBasedRollingPolicy<>(); - policy.setContext(context); - policy.setParent(parent); - policy.setFileNamePattern(LOG_DIRECTORY + "/" + LOG_FILE_NAME.replace(".log", ".%d{yyyy-MM-dd}.log")); - policy.setMaxHistory(MAX_HISTORY); - policy.setTotalSizeCap(FileSize.valueOf(TOTAL_SIZE_CAP)); - return policy; - } - - /** - * 루트 로거를 설정하는 메서드 - * - * @param context LoggerContext 로거 컨텍스트 - * @param consoleAppender ConsoleAppender 콘솔 어펜더 - * @param fileAppender FileAppender 파일 어펜더 - * @author 정안식 - * @since 2025-04-16 - */ - private void configureRootLogger(LoggerContext context, ConsoleAppender consoleAppender, FileAppender fileAppender) { - Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - if (logger instanceof ch.qos.logback.classic.Logger) { - ch.qos.logback.classic.Logger rootLogger = (ch.qos.logback.classic.Logger) logger; - rootLogger.setLevel(Level.INFO); - rootLogger.addAppender(consoleAppender); - rootLogger.addAppender(fileAppender); - } - } + @Value("${log.rolling.directory}") + private String LOG_DIRECTORY; + @Value("${log.rolling.file-name}") + private String LOG_FILE_NAME; + @Value("${log.rolling.pattern}") + private String LOG_PATTERN; + @Value("${log.rolling.max-history}") + private int MAX_HISTORY; + @Value("${log.rolling.total-size-cap}") + private String TOTAL_SIZE_CAP; + + /** + * 로깅 설정을 초기화하는 메서드 + * + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + @PostConstruct + public void configure() { + LoggerContext context = initializeLoggerContext(); + createLogDirectory(); + + ConsoleAppender consoleAppender = createConsoleAppender(context); + FileAppender fileAppender = createFileAppender(context); + + configureRootLogger(context, consoleAppender, fileAppender); + } + + /** + * LoggerContext를 초기화하는 메서드 + * + * @return LoggerContext 초기화된 로거 컨텍스트 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private LoggerContext initializeLoggerContext() { + LoggerContext context = (LoggerContext)LoggerFactory.getILoggerFactory(); + context.reset(); + return context; + } + + /** + * 로그 디렉토리를 생성하는 메서드 + * + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private void createLogDirectory() { + Path logPath = Paths.get(LOG_DIRECTORY); + try { + if (!Files.exists(logPath)) { + Files.createDirectories(logPath); + } + } catch (Exception e) { + throw new RuntimeException("로그 디렉토리 생성 실패", e); + } + } + + /** + * 콘솔 어펜더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return ConsoleAppender 생성된 콘솔 어펜더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private ConsoleAppender createConsoleAppender(LoggerContext context) { + ConsoleAppender appender = new ConsoleAppender<>(); + appender.setContext(context); + appender.setEncoder(createEncoder(context)); + appender.start(); + return appender; + } + + /** + * 파일 어펜더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return FileAppender 생성된 파일 어펜더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private FileAppender createFileAppender(LoggerContext context) { + FileAppender appender = new FileAppender<>(); + appender.setContext(context); + appender.setFile(LOG_DIRECTORY + "/" + LOG_FILE_NAME); + appender.setAppend(true); + appender.setEncoder(createEncoder(context)); + + TimeBasedRollingPolicy rollingPolicy = createRollingPolicy(context, appender); + rollingPolicy.start(); + + appender.start(); + return appender; + } + + /** + * 패턴 레이아웃 인코더를 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @return PatternLayoutEncoder 생성된 패턴 레이아웃 인코더 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private PatternLayoutEncoder createEncoder(LoggerContext context) { + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern(LOG_PATTERN); + encoder.start(); + return encoder; + } + + /** + * 롤링 정책을 생성하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @param parent FileAppender 부모 파일 어펜더 + * @return TimeBasedRollingPolicy 생성된 롤링 정책 + * @author 정안식 + * @modified 2025-04-18 + * @since 2025-04-16 + */ + private TimeBasedRollingPolicy createRollingPolicy(LoggerContext context, + FileAppender parent) { + TimeBasedRollingPolicy policy = new TimeBasedRollingPolicy<>(); + policy.setContext(context); + policy.setParent(parent); + policy.setFileNamePattern(LOG_DIRECTORY + "/" + LOG_FILE_NAME.replace(".log", ".%d{yyyy-MM-dd}.log")); + policy.setMaxHistory(MAX_HISTORY); + policy.setTotalSizeCap(FileSize.valueOf(TOTAL_SIZE_CAP)); + return policy; + } + + /** + * 루트 로거를 설정하는 메서드 + * + * @param context LoggerContext 로거 컨텍스트 + * @param consoleAppender ConsoleAppender 콘솔 어펜더 + * @param fileAppender FileAppender 파일 어펜더 + * @author 정안식 + * @since 2025-04-16 + */ + private void configureRootLogger(LoggerContext context, ConsoleAppender consoleAppender, + FileAppender fileAppender) { + Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + if (logger instanceof ch.qos.logback.classic.Logger) { + ch.qos.logback.classic.Logger rootLogger = (ch.qos.logback.classic.Logger)logger; + rootLogger.setLevel(Level.INFO); + rootLogger.addAppender(consoleAppender); + rootLogger.addAppender(fileAppender); + } + } } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/SwaggerConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/SwaggerConfig.java new file mode 100644 index 0000000..b66f003 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/configuration/SwaggerConfig.java @@ -0,0 +1,21 @@ +package com.likelion.backendplus4.yakplus.common.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("YakPlus API") + .description("YakPlus 프로젝트의 API 문서입니다.") + .version("1.0.0")); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java index ea70228..d19d62e 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/exception/handler/GlobalExceptionHandler.java @@ -1,137 +1,173 @@ package com.likelion.backendplus4.yakplus.common.exception.handler; +import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + import com.likelion.backendplus4.yakplus.common.exception.CustomException; import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; +import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.response.ApiResponse; -import lombok.extern.slf4j.Slf4j; +import java.util.stream.Collectors; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.validation.BindException; -import java.util.stream.Collectors; /** - * 전역 예외 처리 클래스 컨트롤러에서 발생한 예외를 공통적으로 처리한다. + * 전역 예외 처리 클래스 + * 컨트롤러에서 발생한 예외를 공통적으로 처리한다. * - * @modified 2025-04-18 + * @modified 2025-05-03 * @since 2025-04-16 */ -@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - /** - * 공통 에러 응답 생성 메서드 - * - * 예외 로깅 후 ApiResponse.error를 통해 표준화된 에러 응답을 생성한다. - * - * @param status HTTP 상태 코드 - * @param errorCode 에러 코드 문자열 - * @param message 에러 메시지 - * @param ex 발생한 예외 객체 - * @return ResponseEntity> 형태의 에러 응답 - * @author 박찬병 - * @modified 2025-04-18 박찬병 - * @since 2025-04-18 - */ - private ResponseEntity> buildErrorResponse( - HttpStatus status, String errorCode, String message, Throwable ex) { - log.error("{}: {}", ex.getClass().getSimpleName(), ex.getMessage(), ex); - return ApiResponse.error(status, errorCode, message); - } - + // 에러 코드 상수 정의 (정수형 코드 사용) + private static final int ILLEGAL_ARGUMENT_CODE = 300000; + private static final int METHOD_ARGUMENT_NOT_VALID_CODE = 300001; + private static final int BIND_EXCEPTION_CODE = 300002; + private static final int INTERNAL_SERVER_ERROR_CODE = 500000; /** - * CustomException 처리 ErrorCode 인터페이스 기반으로 확장 가능한 방식으로 처리한다. + * CustomException 처리 + * ErrorCode 인터페이스 기반으로 확장 가능한 방식으로 처리한다. * * @param ex CustomException 객체 * @return 에러 응답 * @author 정안식 - * @modified 2025-04-18 박찬병 + * @modified 2025-05-03 박찬병 * @since 2025-04-16 */ @ExceptionHandler(CustomException.class) public ResponseEntity> handleCustomException(CustomException ex) { ErrorCode errorCode = ex.getErrorCode(); return buildErrorResponse( - errorCode.httpStatus(), - String.valueOf(errorCode.codeNumber()), - errorCode.message(), - ex + errorCode.httpStatus(), + errorCode.codeNumber(), + errorCode.message(), + ex ); } /** - * IllegalArgumentException 처리 잘못된 파라미터에 대한 예외 응답 처리 + * IllegalArgumentException 처리 + * 잘못된 파라미터에 대한 예외 응답 처리 * * @param ex 예외 객체 * @return 에러 응답 * @author 정안식 - * @modified 2025-04-18 박찬병 + * @modified 2025-05-03 박찬병 * @since 2025-04-16 */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { - return buildErrorResponse(HttpStatus.BAD_REQUEST, "300000", ex.getMessage(), ex); + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + ILLEGAL_ARGUMENT_CODE, + ex.getMessage(), + ex + ); } /** - * MethodArgumentNotValidException 처리 유효성 검사 실패에 대한 응답 처리 + * MethodArgumentNotValidException 처리 + * 유효성 검사 실패에 대한 응답 처리 * * @param ex 예외 객체 * @return 에러 응답 * @author 정안식 - * @modified 2025-04-18 박찬병 + * @modified 2025-05-03 박찬병 * @since 2025-04-16 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String errorMessage = getErrorMessage(ex); - return buildErrorResponse(HttpStatus.BAD_REQUEST, "300001", errorMessage, ex); + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + METHOD_ARGUMENT_NOT_VALID_CODE, + errorMessage, + ex + ); } /** - * BindException 처리 - GET 요청 파라미터나 폼 바인딩 유효성 실패 시 처리 + * BindException 처리 + * GET 요청 파라미터나 폼 바인딩 유효성 실패 시 처리 * * @param ex BindException 오류 * @return 에러 응답 * @author 박찬병 - * @modified 2025-04-18 박찬병 + * @modified 2025-05-03 박찬병 * @since 2025-04-17 */ @ExceptionHandler(BindException.class) public ResponseEntity> handleBindException(BindException ex) { String errorMessage = getErrorMessage(ex); - return buildErrorResponse(HttpStatus.BAD_REQUEST, "300004", errorMessage, ex); + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + BIND_EXCEPTION_CODE, + errorMessage, + ex + ); } /** - * BindingResult 분석 후 필드별 오류 메시지 조합 + * 기타 모든 예외 처리 + * 정의되지 않은 예외는 내부 서버 오류로 응답 * + * @param ex 예외 객체 * @return 에러 응답 - * @author 박찬병 - * @modified 2025-04-18 박찬병 + * @author 정안식 + * @modified 2025-05-03 박찬병 * @since 2025-04-16 */ - private static String getErrorMessage(BindException ex) { - return ex.getBindingResult().getFieldErrors().stream() - .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) - .collect(Collectors.joining(", ")); + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllExceptions(Exception ex) { + return buildErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + INTERNAL_SERVER_ERROR_CODE, + "내부 서버 오류", + ex + ); } /** - * 기타 모든 예외 처리 정의되지 않은 예외는 내부 서버 오류로 응답 + * 공통 에러 응답 생성 메서드 + * 예외 로깅 후 ApiResponse.error를 통해 표준화된 에러 응답을 생성한다. * - * @param ex 예외 객체 - * @return 에러 응답 - * @author 정안식 - * @modified 2025-04-18 박찬병 + * @param status HTTP 상태 코드 + * @param errorCode 에러 코드 (정수형) + * @param message 에러 메시지 + * @param ex 발생한 예외 객체 + * @return ResponseEntity> 형태의 에러 응답 + * @author 박찬병 + * @modified 2025-05-03 박찬병 + * @since 2025-04-18 + */ + private ResponseEntity> buildErrorResponse( + HttpStatus status, + int errorCode, + String message, + Throwable ex + ) { + log(LogLevel.ERROR, ex.getClass().getSimpleName() + ": " + ex.getMessage(), ex); + return ApiResponse.error(status, String.valueOf(errorCode), message); + } + + /** + * BindingResult 분석 후 필드별 오류 메시지 조합 + * + * @param ex BindException 또는 MethodArgumentNotValidException 객체 + * @return 필드명과 메시지를 콤마로 연결한 오류 문자열 + * @author 박찬병 + * @modified 2025-05-03 박찬병 * @since 2025-04-16 */ - @ExceptionHandler(Exception.class) - public ResponseEntity> handleAllExceptions(Exception ex) { - return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "500000", "내부 서버 오류", ex); + private static String getErrorMessage(BindException ex) { + return ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) + .collect(Collectors.joining(", ")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java index fe2084f..982e143 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LogLevel.java @@ -10,12 +10,12 @@ */ public enum LogLevel { /** - * INFO 레벨 로그 + * TRACE 레벨 로그 */ - INFO { + TRACE { @Override public void log(Logger logger, String traceId, String message) { - logMessage(logger::info, traceId, message); + logMessage(logger::trace, traceId, message); } }, /** @@ -27,6 +27,24 @@ public void log(Logger logger, String traceId, String message) { logMessage(logger::debug, traceId, message); } }, + /** + * INFO 레벨 로그 + */ + INFO { + @Override + public void log(Logger logger, String traceId, String message) { + logMessage(logger::info, traceId, message); + } + }, + /** + * WARN 레벨 로그 + */ + WARN { + @Override + public void log(Logger logger, String traceId, String message) { + logMessage(logger::warn, traceId, message); + } + }, /** * ERROR 레벨 로그 */ diff --git a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java index 7be5334..36ae238 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/common/util/log/LoggerWithTraceId.java @@ -82,24 +82,6 @@ private static String makeTraceId() { return traceId; } - /** - * TraceId를 검증하는 메서드 - * - * @param traceId String 검증할 TraceId - * @throws IllegalStateException 유효하지 않은 TraceId - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private static void validateTraceId(String traceId) { - if (traceId == null) { - throw new IllegalStateException("TraceId가 null입니다. MDC에 traceId가 설정되어 있는지 확인하세요."); - } - if (traceId.trim().isEmpty()) { - throw new IllegalStateException("TraceId가 빈 문자열입니다. 유효한 traceId를 설정해주세요."); - } - } - /** * 호출한 클래스의 이름을 가져오는 메서드 * diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java deleted file mode 100644 index 9ad4592..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -public interface DrugApprovalDetailScraper { - void requestUpdateRawData(); - - void requestUpdateAllRawData(); - - void requestUpdateAllRawDataByJdbc(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java deleted file mode 100644 index 63eb994..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugApprovalDetailScraperImpl.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc.GovDrugJdbcRepository; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugDetailJpaRepository; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.MaterialParser; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser.XMLParser; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@Slf4j -@Component -@RequiredArgsConstructor -public class DrugApprovalDetailScraperImpl implements DrugApprovalDetailScraper { - private final ObjectMapper objectMapper; - private final RestTemplate restTemplate; - private final ApiUriCompBuilder apiUriCompBuilder; - private final GovDrugDetailJpaRepository govDrugDetailJpaRepository; - private final GovDrugJdbcRepository govDrugJdbcRepository; - - @Override - public void requestUpdateRawData() { - log.info("API 데이터 요청"); - String response = restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(1), String.class); - log.debug("API Response: {}", response); - - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List drugs = toListFromJson(items); - govDrugDetailJpaRepository.saveAllAndFlush(drugs); - } - - - @Override - public void requestUpdateAllRawData() { - int pageNo = 1; - int receivedCount = 0; - int savedCountWithoutDuplicates = 0; - - String response = fetchPage(pageNo); - int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); - - while (hasMoreData(receivedCount, totalCount)) { - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List drugs = toListFromJson(items); - receivedCount += drugs.size(); - - // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) - int uniqueItems = deduplicateByItemSeq(drugs); - savedCountWithoutDuplicates += uniqueItems; - - govDrugDetailJpaRepository.saveAllAndFlush(drugs); - - log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", - pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); - - response = fetchPage(++pageNo); - } - - } - - @Override - public void requestUpdateAllRawDataByJdbc() { - int pageNo = 1; - int receivedCount = 0; - int savedCountWithoutDuplicates = 0; - - String response = fetchPage(pageNo); - int totalCount = ApiResponseMapper.getTotalCountFromResponse(response); - - while (hasMoreData(receivedCount, totalCount)) { - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List drugs = toListFromJson(items); - receivedCount += drugs.size(); - - // item_seq 기준 중복 제거된 약품 개수 유지 (실제 db에 저장된 데이터와 같은 지 비교용) - int uniqueItems = deduplicateByItemSeq(drugs); - savedCountWithoutDuplicates += uniqueItems; - - govDrugJdbcRepository.saveAll(drugs); - - log.info("Page {}, received: {}, saved (unique): {}, totalReceived: {}, totalUniqueSaved: {}", - pageNo, drugs.size(), uniqueItems, receivedCount, savedCountWithoutDuplicates); - - response = fetchPage(++pageNo); - } - } - - private List toListFromJson(JsonNode items) { - - log.info("items 약품 객체로 맵핑"); - try { - List apiDataDrugDetails = toApiDetails(items); - for (int i = 0; i < apiDataDrugDetails.size(); i++) { - GovDrugDetailEntity drugDetail = apiDataDrugDetails.get(i); - JsonNode item = items.get(i); - log.debug("item seq: " + item.get("ITEM_SEQ").asText()); - - String materialRawData = item.get("MATERIAL_NAME").asText(); - String materialInfo = MaterialParser.parseMaterial(materialRawData); - drugDetail.changeMaterialInfo(materialInfo); - - String efficacyXmlText = item.get("EE_DOC_DATA").asText(); - String efficacy = XMLParser.toJson(efficacyXmlText); - drugDetail.changeEfficacy(efficacy); - - String usageXmlText = items.get(i).get("UD_DOC_DATA").asText(); - String usages = XMLParser.toJson(usageXmlText); - drugDetail.changeUsage(usages); - - String precautionxmlText = items.get(i).get("NB_DOC_DATA").asText(); - String precautions = XMLParser.toJson(precautionxmlText); - drugDetail.changePrecaution(precautions); - } - return apiDataDrugDetails; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private List toApiDetails(JsonNode items) { - try { - return objectMapper.readValue(items.toString(), - new TypeReference>() { - }); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - // private JsonNode toJsonFromXml(String usageXmlText) throws JsonProcessingException { - // XmlMapper xmlMapper = new XmlMapper(); - // - // JsonNode jsonNode = xmlMapper.readTree(usageXmlText) - // .path("SECTION") - // .path("ARTICLE"); - // return jsonNode; - // } - - // TODO: 추후 삭제 예정 - // private String replaceText(String text){ - // return text.replace("ᆞ ", "&") - // .replace("• ","") - // .replace("〜 ", "~"); - // } - - private int deduplicateByItemSeq(List drugs) { - // itemseq 기준으로 set에 저장 --> set은 중복 허용하지 않으므로 item seq 다 넣으면 알아서 중복 없이 저장됨 - Set uniqueItems = new HashSet<>(); - - for (GovDrugDetailEntity drug : drugs) { - uniqueItems.add(drug.getDrugId()); - } - return uniqueItems.size(); - } - - private String fetchPage(int pageNo) { - return restTemplate.getForObject(apiUriCompBuilder.getUriForDetailApi(pageNo), String.class); - } - - private boolean hasMoreData(int receivedCount, int totalCount) { - return receivedCount < totalCount; - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java deleted file mode 100644 index eabec2b..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -import java.util.List; - -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; - -public interface DrugDataService { - List findAllRawDrug(); -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java deleted file mode 100644 index 13dba49..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugDataServiceImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -import static java.util.stream.Collectors.*; - -import java.util.List; - -import org.springframework.stereotype.Service; - -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa.GovDrugJpaRepository; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Slf4j -@Service -public class DrugDataServiceImpl implements DrugDataService { - private final GovDrugJpaRepository govDrugJpaRepository; - - @Override - public List findAllRawDrug() { - log.info("findAllRawDrug called"); - return govDrugJpaRepository.findAll().stream() - .map(DrugDataMapper::toDomainFromEntity) - .collect(toList()); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java deleted file mode 100644 index 78a4359..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/application/service/DrugImageGovScraper.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.application.service; - -import java.net.URI; -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiResponseMapper; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.api.ApiUriCompBuilder; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.ApiDataDrugImgRepo; -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@Slf4j -@RequiredArgsConstructor -public class DrugImageGovScraper { - private final ApiUriCompBuilder uriCompBuilder; - private final RestTemplate restTemplate; - private final ApiDataDrugImgRepo imgRepo; - private final ObjectMapper objectMapper; - - @Transactional - public void getApiData(){ - log.info("의약품 개요 정보 API 호출 시작"); - - URI uriForImgApi = uriCompBuilder.getUriForImgApi(1); - - String response = restTemplate.getForObject(uriForImgApi, String.class); - JsonNode items = ApiResponseMapper.getItemsFromResponse(response); - List imgDatas = null; - try { - imgDatas = objectMapper.readValue(items.toString(), - new TypeReference>() {}); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - imgRepo.saveAllAndFlush(imgDatas); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java deleted file mode 100644 index 75ed781..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; -import com.likelion.backendplus4.yakplus.drug.domain.model.vo.WarningType; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class GovDrug { - private Long drugId; - private String drugName; - private String company; - private LocalDate permitDate; - private boolean isGeneral; - private String materialInfo; - private String storeMethod; - private String validTerm; - private String efficacy; - private String usage; - private String precaution; - private String imageUrl; - - public List getMaterialInfo() { - List matrerials = new ArrayList<>(); - - try { - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode json = objectMapper.readTree(materialInfo); - - if (json.isArray()) { - for (JsonNode node : json) { - Material ingredient = objectMapper.treeToValue(node, Material.class); - matrerials.add(ingredient); - } - } - } - catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - - return matrerials; - } - - // public List getEfficacy() { - // List efficacys = new ArrayList<>(); - // try { - // ObjectMapper objectMapper = new ObjectMapper(); - // JsonNode json = objectMapper.readTree(this.efficacy); - // for (JsonNode section : json.get("sections")) { - // for (JsonNode article : section.get("articles")) { - // for (JsonNode paragraph : article.get("paragraphs")) { - // efficacys.add(paragraph.get("text").asText()); - // } - // } - // } - // } catch (JsonProcessingException e) { - // //TODO: 예외처리 - // throw new RuntimeException(e); - // } - // return efficacys; - // } - - public Map> getPrecaution() { - ObjectMapper objectMapper = new ObjectMapper(); - Map> result = new LinkedHashMap<>(); - - try { - JsonNode json = objectMapper.readTree(this.precaution); - JsonNode articles = json.get("sections").get(0).get("articles"); - - for (JsonNode article : articles) { - String rawTitle = article.get("title").asText(); - WarningType type = WarningType.fromLabel(rawTitle); - - List texts = new ArrayList<>(); - for (JsonNode paragraph : article.get("paragraphs")) { - texts.add(paragraph.get("text").asText()); - } - - result.put(type, texts); - } - - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - - return result; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java deleted file mode 100644 index bb7ceb0..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrugDetail.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model; - -import java.time.LocalDate; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class GovDrugDetail { - private Long drugId; - private String drugName; - private String company; - private LocalDate permitDate; - private boolean isGeneral; - private String materialInfo; - private String storeMethod; - private String validTerm; - private String efficacy; - private String usage; - private String precaution; - - public JsonNode toJson(String json) { - try { - return new ObjectMapper().readValue(json, JsonNode.class); - } catch (JsonProcessingException e) { - //TODO 에러 로그 처리 필요합니다. - throw new RuntimeException(e); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java deleted file mode 100644 index 75f29df..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/MaterialInfo.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model.vo; - -import java.util.List; - -public class MaterialInfo { - private String totalAmount; - private List ingredients; -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java deleted file mode 100644 index 38640bf..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/WarningType.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model.vo; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; - -public enum WarningType { - WARNING("경고"), - DO_NOT_ADMINISTER("다음 환자에는 투여하지 말 것."), - CAUTION_ADMINISTER("다음 환자에는 신중히 투여할 것."), - ADVERSE_REACTIONS("이상반응"), - GENERAL_CAUTION("일반적 주의"), - PREGNANCY("임부에 대한 투여"), - PREGNANCY2("임부, 수유부, 가임여성, 신생아, 유아, 소아, 고령자에 대한 투여"), - PEDIATRIC("소아에 대한 투여"), - ELDERLY("고령자에 대한 투여"), - OVERDOSE("과량투여시의 처치"), - USAGE_NOTES("적용상의 주의"), - STORE_NOTES("보관 및 취급상의 주의사항"); - - private final String label; - - WarningType(String label) { - this.label = label; - } - - @JsonValue - public String getLabel() { - return label; - } - - @JsonCreator - public static WarningType fromLabel(String title) { - String cleaned = removeLeadingNumber(title); - for (WarningType type : values()) { - if (type.label.equals(cleaned)) { - return type; - } - } - throw new IllegalArgumentException("Unknown title: " + title); - } - - private static String removeLeadingNumber(String title) { - return title.replaceFirst("^\\d+\\.\\s*", ""); // 숫자 + 점 + 공백 제거 - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java deleted file mode 100644 index 7f47c4d..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/ScraperException.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.exception; - -import com.likelion.backendplus4.yakplus.common.exception.CustomException; -import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; - -public class ScraperException extends CustomException { - private final ErrorCode errorCode; - - public ScraperException(ErrorCode errorCode) { - super(errorCode); - this.errorCode = errorCode; - } - - @Override - public ErrorCode getErrorCode() { - return errorCode; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java deleted file mode 100644 index 017d57d..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/exception/error/ScraperErrorCode.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.exception.error; - -import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum ScraperErrorCode implements ErrorCode { - - DB_ERROR_PERMIT_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300001, "허가 정보를 조회하는데 실패했습니다."), - DB_ERROR_IMAGE_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300002, "이미지 정보를 조회하는데 실패했습니다."), - DB_ERROR_COMBINED_INFO(HttpStatus.INTERNAL_SERVER_ERROR, 300003, "결합된 정보를 조회하는데 실패했습니다."), - API_CONNECT_FAIL(HttpStatus.BAD_GATEWAY, 400001, "외부 API 연결에 실패했습니다."), - PARSING_ERROR(HttpStatus.BAD_REQUEST, 400001, "데이터 파싱에 실패했습니다."); - - private final HttpStatus httpStatus; - private final int code; - private final String message; - - @Override - public HttpStatus httpStatus() { - return null; - } - - @Override - public int codeNumber() { - return 0; - } - - @Override - public String message() { - return ""; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java deleted file mode 100644 index 3b4cbdf..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/ApiDataDrugImgEntity.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.ToString; - -@Entity -@ToString -@Table(name="API_DATA_DRUG_IMG") -public class ApiDataDrugImgEntity { - @Id - @JsonProperty("ITEM_SEQ") - private Long seq; - - @JsonProperty("BIG_PRDT_IMG_URL") - private String imgUrl; -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java deleted file mode 100644 index 6c06157..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/entity/GovDrugDetailEntity.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.time.LocalDate; - -@Entity -@Builder -@Getter -@NoArgsConstructor -@AllArgsConstructor -@ToString -@Table(name = "gov_drug_detail") -public class GovDrugDetailEntity { - - @Id - @JsonProperty("ITEM_SEQ") - @Column( name= "ITEM_SEQ") - private Long drugId; - - @JsonProperty("ITEM_NAME") - @Column( name= "ITEM_NAME", columnDefinition = "TEXT") - private String drugName; - - @JsonProperty("ENTP_NAME") - @Column( name= "ENTP_NAME") - private String company; - - @JsonProperty("ITEM_PERMIT_DATE") - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd") - @Column( name= "ITEM_PERMIT_DATE") - private LocalDate permitDate; - - @Column(name = "ETC_OTC_CODE") - private boolean isGeneral; - - @Column(name = "MATERIAL_NAME", columnDefinition = "JSON") - private String materialInfo; - - @JsonProperty("STORAGE_METHOD") - @Column(name = "STORAGE_METHOD", columnDefinition = "TEXT") - private String storeMethod; - - @JsonProperty("VALID_TERM") - @Column(name = "VALID_TERM") - private String validTerm; - - @Column(name = "EE_DOC_DATA", columnDefinition = "JSON") - private String efficacy; - - @Column(name = "UD_DOC_DATA", columnDefinition = "JSON") - private String usage; - - @Column(name = "NB_DOC_DATA", columnDefinition = "JSON") - private String precaution; - - @JsonCreator - public GovDrugDetailEntity(@JsonProperty("ETC_OTC_CODE") String drugType) { - this.isGeneral = !"전문의약품".equals(drugType); - } - - public void changeMaterialInfo(String materialInfo){ - this.materialInfo = materialInfo; - } - - public void changeUsage(String usage) { - this.usage = usage; - } - - public void changeEfficacy(String efficacy) { - this.efficacy = efficacy; - } - - public void changePrecaution(String precaution) { - this.precaution = precaution; - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java deleted file mode 100644 index 6b008bc..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/GovDrugJdbcRepository.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc; - -import java.util.List; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class GovDrugJdbcRepository { - private final JdbcTemplate jdbc; - - @Transactional - public void saveAll(List entities) { - String sql = "" - + "INSERT INTO gov_drug_detail " - + " (ITEM_SEQ, ITEM_NAME, ENTP_NAME, " - + " ITEM_PERMIT_DATE, ETC_OTC_CODE, MATERIAL_NAME, " - + " STORAGE_METHOD, VALID_TERM, EE_DOC_DATA, " - + " UD_DOC_DATA, NB_DOC_DATA) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + "ON DUPLICATE KEY UPDATE " - + " ITEM_NAME = VALUES(ITEM_NAME), " - + " ENTP_NAME = VALUES(ENTP_NAME), " - + " ITEM_PERMIT_DATE = VALUES(ITEM_PERMIT_DATE), " - + " ETC_OTC_CODE = VALUES(ETC_OTC_CODE), " - + " MATERIAL_NAME = VALUES(MATERIAL_NAME), " - + " STORAGE_METHOD = VALUES(STORAGE_METHOD), " - + " VALID_TERM = VALUES(VALID_TERM), " - + " EE_DOC_DATA = VALUES(EE_DOC_DATA), " - + " UD_DOC_DATA = VALUES(UD_DOC_DATA), " - + " NB_DOC_DATA = VALUES(NB_DOC_DATA)"; - jdbc.batchUpdate(sql, new JdbcBatchSetter(entities)); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java deleted file mode 100644 index 7310690..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jdbc/JdbcBatchSetter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jdbc; - -import java.sql.Date; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Types; -import java.time.LocalDate; -import java.util.List; - -import org.springframework.jdbc.core.BatchPreparedStatementSetter; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class JdbcBatchSetter implements BatchPreparedStatementSetter { - - private final List entities; - - @Override - public void setValues(PreparedStatement ps, int i) throws SQLException { - GovDrugDetailEntity e = entities.get(i); - ps.setLong (1, e.getDrugId()); - ps.setString (2, e.getDrugName()); - ps.setString (3, e.getCompany()); - - LocalDate permit = e.getPermitDate(); - if (permit != null) { - ps.setDate(4, Date.valueOf(permit)); - } else { - ps.setNull(4, Types.DATE); - } - - ps.setBoolean(5, e.isGeneral()); - ps.setString (6, e.getMaterialInfo()); - ps.setString (7, e.getStoreMethod()); - ps.setString (8, e.getValidTerm()); - ps.setString (9, e.getEfficacy()); - ps.setString (10, e.getUsage()); - ps.setString (11, e.getPrecaution()); - } - - @Override - public int getBatchSize() { - return entities.size(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java deleted file mode 100644 index 12a8800..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/ApiDataDrugImgRepo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.ApiDataDrugImgEntity; - -@Repository -public interface ApiDataDrugImgRepo extends JpaRepository { -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java deleted file mode 100644 index 4ca4706..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/adapter/persistence/repository/jpa/GovDrugDetailJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.entity.GovDrugDetailEntity; - -import jakarta.transaction.Transactional; - -@Repository -public interface GovDrugDetailJpaRepository extends JpaRepository { - - @Override - @Transactional - List saveAllAndFlush(Iterable entities); -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java deleted file mode 100644 index a449759..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/config/ApiRestTemplateConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -/** - * Api 요청을 보내기 위한 RestTemplate 빈 생성 - * - * @since 2025-04-15 - * @author 함예정 - */ -@Configuration -public class ApiRestTemplateConfig { - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java deleted file mode 100644 index c48cf28..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiResponseMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.api; - -import org.springframework.stereotype.Component; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class ApiResponseMapper { - - public static JsonNode getItemsFromResponse(String response) { - log.info("응답에서 items 값 추출"); - try { - return new ObjectMapper().readTree(response) - .path("body") - .path("items"); - } catch (JsonProcessingException e) { - log.error("items 추출 실패"); - //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 - throw new RuntimeException(e); - } - } - - public static int getTotalCountFromResponse(String response) { - log.info("응답에서 데이터 사이즈 추출"); - return 10_000; - // try { - // return new ObjectMapper().readTree(response) - // .path("body") - // .path("totalCount") - // .asInt(); - // } catch (JsonProcessingException e) { - // log.error("totalCount 추출 실패"); - // //TODO: CustomException 만들고, ControllerAdvice로 예외처리 필요 - // throw new RuntimeException(e); - // } - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java deleted file mode 100644 index f3312f6..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/api/ApiUriCompBuilder.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.api; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; - -/*** - * API 요청 URI 객체 생성 빌더 - * - * application.yml에서 주입되는 속성 값으로, - * API HOST, PATH를 확인해 URI 객체를 만듭니다. - * - * @since 2025-04-15 - * @author 함예정 - */ -@Component -public class ApiUriCompBuilder { - private final String SERVICE_KEY; - private final String HOST; - private final String API_DETAIL_PATH; - private final String API_IMG_PATH ; - private final int NUM_OF_ROWS; - - public ApiUriCompBuilder(@Value("${gov.host}") String host, - @Value("${gov.serviceKey}") String serviceKey, - @Value("${gov.path.detail}") String pathDetail, - @Value("${gov.path.img}") String pathImg, - @Value("${gov.numOfRows}") int numOfRows) { - this.HOST = host; - this.SERVICE_KEY = serviceKey; - this.API_DETAIL_PATH = pathDetail; - this.API_IMG_PATH = pathImg; - this.NUM_OF_ROWS = numOfRows; - } - - /*** - * 입력 받은 path를 반영해 URI 객체를 생성, 반환 - * - * @param path API 요청 경로 - * @return URI - * - * @since 2025-04-15 - * @author 함예정 - */ - private URI getUri(String path, int pageNo) { - return UriComponentsBuilder.newInstance() - .scheme("https") - .host(HOST) - .port(443) - .path(path) - .queryParam("serviceKey", SERVICE_KEY) - .queryParam("type", "json") - .queryParam("pageNo", pageNo) - .queryParam("numOfRows", NUM_OF_ROWS) - .build(true) - .toUri(); - } - - /*** - * 식품의약품안전처 의약품 제품 허가 상세 정보 URI 반환 - * @return URI 제품 허가 상세 정보 - * - * @since 2025-04-15 - * @author 함예정 - */ - public URI getUriForDetailApi(int pageNo) { - return getUri(API_DETAIL_PATH, pageNo); - } - - /*** - * 식품의약품안전처 의약품 제품 허가 목록 URI 반환 - * @return URI 제품 허가 목록 - * - * @since 2025-04-15 - * @author 함예정 - */ - public URI getUriForImgApi(int pageNo) { - return getUri(API_IMG_PATH, pageNo); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java deleted file mode 100644 index a1c32d7..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/mapper/DrugDataMapper.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper; - -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; - -/** - * GovDrugEntity와 도메인 모델(GovDrug) 간의 매핑을 담당하는 클래스. - * Entity 객체를 비즈니스 도메인 객체로 변환한다. - * - * @since 2025-04-30 - */ -public class DrugDataMapper { - - /** - * GovDrugEntity로부터 GovDrug 도메인 객체를 생성합니다. - * - * @param e 변환할 GovDrugEntity 객체 - * @return 변환된 GovDrug 도메인 객체 - * - * @author 함예정 - * @since 2025-04-30 - */ - public static GovDrug toDomainFromEntity(GovDrugEntity e) { - return GovDrug.builder() - .drugId(e.getId()) - .drugName(e.getDrugName()) - .company(e.getCompany()) - .permitDate(e.getPermitDate()) - .isGeneral(e.isGeneral()) - .materialInfo(e.getMaterialInfo()) - .storeMethod(e.getStoreMethod()) - .validTerm(e.getValidTerm()) - .efficacy(e.getEfficacy()) - .usage(e.getUsage()) - .precaution(e.getPrecaution()) - .imageUrl(e.getImageUrl()) - .build(); - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java deleted file mode 100644 index ee4f867..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/MaterialParser.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -public class MaterialParser { - public static String parseMaterial(String raw) throws Exception { - ObjectMapper result = new ObjectMapper(); - ArrayNode resultArray = result.createArrayNode(); - String[] blocks = splitBlock(raw); - parsingblocksAndPutArrayItem(blocks, resultArray); - return convertString(result, resultArray); - } - - private static void parsingblocksAndPutArrayItem(String[] blocks, ArrayNode resultArray) { - for (String block : blocks) { - block = block.trim(); - if (block.isEmpty()) { - continue; - } - String[] pairs = splitByPipe(block); - ObjectNode item = makeItem(pairs); - resultArray.add(item); - } - } - - private static String convertString(ObjectMapper objectMapper, ArrayNode arrayNode) { - try { - return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(arrayNode); - } catch (JsonProcessingException e) { - //TODO String 변환실패 - throw new RuntimeException(e); - } - } - - private static ObjectNode makeItem(String[] pairs) { - ObjectNode item = new ObjectMapper().createObjectNode(); - for (String pair : pairs) { - String[] kv = pair.split(":", 2); - String key = kv[0].trim(); - String value = ""; - if(kv.length == 2){ - value = kv[1].trim(); - } - item.put(key, value); - } - return item; - } - - private static String[] splitByPipe(String block) { - return block.split("\\|"); - } - - private static String[] splitBlock(String raw) { - return raw.split(";"); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java deleted file mode 100644 index 8c7680b..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/infrastructure/support/parser/XMLParser.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.infrastructure.support.parser; - -import java.io.IOException; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -public class XMLParser { - public static String toJson(String xml) { - - if(isXmlNull(xml)) { - return "{\"\": \"\"}"; - } - - Document doc = parseXmlString(xml); - Element root = doc.getDocumentElement(); - - List allSections = new ArrayList<>(); - List allArticles = new ArrayList<>(); - List allParagraphs = new ArrayList<>(); - - Map sectionMap = new HashMap<>(); - Map articleMap = new HashMap<>(); - - DocTag docTag = new DocTag(root, allSections); - parseSesctions(root, allSections, sectionMap); - parseArticles(root, allArticles, articleMap, sectionMap); - parseParagraph(root, allParagraphs, articleMap); - return convertJson(docTag); - } - private static final ObjectMapper mapper = new ObjectMapper(); - - private static final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); - - private static String convertJson(DocTag docTag) { - try { - return mapper.writeValueAsString(docTag); - //TODO: 예외처리 후 삭제 - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - private static void parseParagraph(Element root, List allParagraphs, Map articleMap) { - NodeList paraNodes = root.getElementsByTagName("PARAGRAPH"); - - if(paraNodes.getLength() != 0){ - for (int i = 0; i < paraNodes.getLength(); i++) { - Element paragraphElement = (Element) paraNodes.item(i); - ParagraphTag paragraphTag = new ParagraphTag(); - paragraphTag.tagName = paragraphElement.getAttribute("tagName"); - paragraphTag.textIndent = paragraphElement.getAttribute("textIndent"); - paragraphTag.marginLeft = paragraphElement.getAttribute("marginLeft"); - paragraphTag.text = paragraphElement.getTextContent().trim(); - - allParagraphs.add(paragraphTag); - mapSectionFromArticle(articleMap, paragraphTag, paragraphElement); - } - } - - } - - private static void parseArticles(Element root, List allArticles, - Map articleMap, - Map sectionMap) { - NodeList artNodes = root.getElementsByTagName("ARTICLE"); - if(artNodes.getLength() > 0) { - for (int i = 0; i < artNodes.getLength(); i++) { - Element artElement = (Element) artNodes.item(i); - ArticleTag articleTag = new ArticleTag(); - articleTag.title = artElement.getAttribute("title"); - articleTag.paragraphs = new ArrayList<>(); - - allArticles.add(articleTag); - articleMap.put(artElement, articleTag); - mapSectionFromArticle(sectionMap, articleTag, artElement); - } - } - - } - - private static void mapSectionFromArticle(Map map, Tags tags, Element element) { - Element parentElement = (Element) element.getParentNode(); - Tags parentTag = map.get(parentElement); - if (parentTag != null) { - parentTag.addTag(tags); - } - } - - private static void parseSesctions(Element root, List allSections, Map sectionMap) { - NodeList secNodes = root.getElementsByTagName("SECTION"); - - if(secNodes.getLength() > 0) { - for (int i = 0; i < secNodes.getLength(); i++) { - Element secEl = (Element) secNodes.item(i); - SectionTag secDto = new SectionTag(); - secDto.title = secEl.getAttribute("title"); - secDto.articles = new ArrayList<>(); - - allSections.add(secDto); - sectionMap.put(secEl, secDto); - } - } - } - - private static Document parseXmlString(String xml) { - //TODO: 예외처리 후 삭제 - try { - return documentBuilderFactory.newDocumentBuilder() - .parse(new InputSource(new StringReader(xml))); - } catch (SAXException e) { - // System.out.println(xml); - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } catch (ParserConfigurationException e) { - //TODO DocumentBulider 생성 실패 - throw new RuntimeException(e); - } - } - - private static boolean isXmlNull(String xml) { - if (xml == null || xml.trim().isEmpty() || xml == "null") { - return true; - } else { - return false; - } - } - - private static class DocTag implements Tags { - public String title; - public String type; - public List sections; - - DocTag(Element root, List sections) { - this.title = root.getAttribute("title"); - this.type = root.getAttribute("type"); - this.sections = sections; - } - - @Override - public void addTag(Tags tags) { - sections.add((SectionTag) tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof DocTag; - } - } - - private static class SectionTag implements Tags { - public String title; - public List articles; - - @Override - public void addTag(Tags tags) { - articles.add((ArticleTag)tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof SectionTag; - } - } - - private static class ArticleTag implements Tags { - public String title; - public List paragraphs; - - @Override - public void addTag(Tags tags) { - paragraphs.add((ParagraphTag) tags); - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof ArticleTag; - } - } - - public static class ParagraphTag implements Tags { - public String tagName; - public String textIndent; - public String marginLeft; - public String text; - - @Override - public void addTag(Tags tags) { - //TODO: addTag Exception 하위 없음 - } - - @Override - public boolean equalsClass(Tags tags) { - return tags instanceof ParagraphTag; - } - } - - public static interface Tags { - void addTag(Tags tags); - boolean equalsClass(Tags tags); - } -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java deleted file mode 100644 index c36be75..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDataTestController.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller; - -import java.util.List; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.likelion.backendplus4.yakplus.drug.application.service.DrugDataService; -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RestController -@Slf4j -@RequiredArgsConstructor -public class DrugDataTestController { - private final DrugDataService dragDataService; - - @GetMapping("/data/all") - public List getAllData(){ - log.info("getAllData"); - return dragDataService.findAllRawDrug(); - } - -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java deleted file mode 100644 index 7c87340..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugDetailController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; - -import com.likelion.backendplus4.yakplus.drug.application.service.DrugApprovalDetailScraper; - -import lombok.RequiredArgsConstructor; - -@Controller -@RequiredArgsConstructor -public class DrugDetailController { - private final DrugApprovalDetailScraper scraperUseCase; - - @GetMapping("/gov/api/parser/detail/start") - public ResponseEntity saveAPIData(){ - scraperUseCase.requestUpdateRawData(); - return ResponseEntity.ok().build(); - } - - @GetMapping("/gov/api/parser/detail/startAll") - public ResponseEntity saveAPIDataAll(){ - scraperUseCase.requestUpdateAllRawData(); - return ResponseEntity.ok().build(); - } - - @PostMapping("/gov/api/parser/detail/startAll") - public ResponseEntity saveAPIDataAllByJdbc(){ - scraperUseCase.requestUpdateAllRawDataByJdbc(); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java b/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java deleted file mode 100644 index cf68610..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/presentation/controller/DrugImageController.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.likelion.backendplus4.yakplus.drug.presentation.controller; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.likelion.backendplus4.yakplus.drug.application.service.DrugImageGovScraper; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -public class DrugImageController { - private final DrugImageGovScraper imageScraper; - - @GetMapping("/gov/api/parser/image/start") - public void test(){ - imageScraper.getApiData(); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java deleted file mode 100644 index 580fc12..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.likelion.backendplus4.yakplus.logtest; - -import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; -import com.likelion.backendplus4.yakplus.common.util.log.LogMessage; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; - -/** - * 데이터 처리를 위한 컨트롤러 클래스 - * - * @modified 2025-04-18 - * @since 2025-04-16 - */ -@RestController -@RequestMapping("/api") -@RequiredArgsConstructor -public class MyController { - - private final MyService myService; - - /** - * 데이터 처리 요청을 처리하는 메서드 - * - * @return ResponseEntity 처리 결과 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - @GetMapping("/process") - public ResponseEntity process() { - log(LogLevel.INFO, LogMessage.DATA_PROCESSING_START.getMessage()); - - try { - String result = myService.processData(); - log(LogLevel.INFO, LogMessage.DATA_PROCESSING_SUCCESS.getMessage()); - return ResponseEntity.ok(result); - } catch (Exception e) { - log(LogLevel.ERROR, LogMessage.DATA_PROCESSING_ERROR.getMessage(), e); - return ResponseEntity.internalServerError() - .body(LogMessage.DATA_PROCESSING_ERROR.getMessage()); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java b/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java deleted file mode 100644 index 3480559..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/logtest/MyService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.likelion.backendplus4.yakplus.logtest; - -import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; -import com.likelion.backendplus4.yakplus.common.util.log.LogMessage; -import org.springframework.stereotype.Service; - -import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; - -/** - * 데이터 처리를 위한 서비스 클래스 - * - * @modified 2025-04-18 - * @since 2025-04-16 - */ -@Service -public class MyService { - - private static final long PROCESSING_DELAY = 1000L; - - /** - * 데이터를 처리하는 메서드 - * - * @return String 처리된 데이터 결과 - * @throws RuntimeException 처리 중 오류 발생 시 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - public String processData() { - log(LogLevel.INFO, LogMessage.SERVICE_DATA_PROCESSING_START.getMessage()); - - try { - simulateProcessing(); - log(LogLevel.INFO, LogMessage.SERVICE_DATA_PROCESSING_SUCCESS.getMessage()); - return LogMessage.PROCESSED_DATA_RESULT.getMessage(); - } catch (InterruptedException e) { - log(LogLevel.ERROR, LogMessage.SERVICE_DATA_PROCESSING_ERROR.getMessage(), e); - Thread.currentThread().interrupt(); - throw new RuntimeException(LogMessage.SERVICE_DATA_PROCESSING_ERROR.getMessage(), e); - } - } - - /** - * 처리 과정을 시뮬레이션하는 private 메서드 - * - * @throws InterruptedException 인터럽트 발생 시 - * @author 정안식 - * @modified 2025-04-18 - * @since 2025-04-16 - */ - private void simulateProcessing() throws InterruptedException { - Thread.sleep(PROCESSING_DELAY); - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java index 5b18dca..7fb0bb4 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/in/SearchDrugUseCase.java @@ -1,14 +1,12 @@ package com.likelion.backendplus4.yakplus.search.application.port.in; + +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; -import java.util.List; - /** * 의약품 검색 관련 유스케이스를 정의하는 인터페이스입니다. * 다양한 조건에 따라 의약품을 검색하거나 자동완성 기능을 제공합니다. @@ -22,20 +20,95 @@ public interface SearchDrugUseCase { * * @param drugId 조회할 의약품의 고유 ID * @return 상세 검색 응답 객체 - * * @author 함예정 * @since 2025-04-30 */ DetailSearchResponse searchByDrugId(Long drugId); + /** + * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 + * 리스트로 매핑하여 반환한다. + * + * @param drugSearchNatural 검색어 및 페이지 정보를 담은 객체 + * @return 검색 결과 객체 리스트 + * @throws SearchException 검색어가 유효하지 않은 경우 발생 + * @author 정안식 + * @modified 2025-05-02 + * - 25.05.02 - SearchResponseList를 반환하도록 수정 + * @since 2025-04-22 + */ SearchResponseList searchDrugByNatural(DrugSearchNatural drugSearchNatural); + /** + * 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다. + * Elasticsearch에서 Suggest API 등을 활용하여 추천 결과를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO + * @author 박찬병 + * @modified 2025-04-25 + * @since 2025-04-24 + */ AutoCompleteStringList getSymptomAutoComplete(String q); + /** + * 사용자 입력 문자열을 바탕으로 약품명 자동완성 추천 키워드를 조회합니다. + *

+ * Elasticsearch Suggest API를 활용하여 약품명과 관련된 자동완성 키워드 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO (AutoCompleteStringList) + * @author 박찬병 + * @modified 2025-04-30 + * @since 2025-04-29 + */ AutoCompleteStringList getDrugNameAutoComplete(String q); - SearchResponseList searchDrugByDrugName(String q, int page, int size); + /** + * 사용자 입력 문자열을 바탕으로 성분명 자동완성 추천 키워드를 조회합니다. + *

+ * Elasticsearch Suggest API를 활용하여 성분명과 관련된 자동완성 키워드 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO (AutoCompleteStringList) + * @author 박찬병 + * @modified 2025-05-03 + * @since 2025-05-03 + */ + AutoCompleteStringList getIngredientAutoComplete(String q); - SearchResponseList searchDrugBySymptom(String q, int page, int size); + /** + * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. + * + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 + * @modified 2025-04-27 + * @since 2025-04-25 + */ + SearchResponseList searchDrugBySymptom(DrugSearchNatural drugSearch); + + + /** + * 주어진 약품명 키워드로 약품 리스트를 검색하여 반환합니다. + * + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 + * @modified 2025-05-03 + * @since 2025-04-29 + */ + SearchResponseList searchDrugByDrugName(DrugSearchNatural drugSearch); + + /** + * 주어진 성분명 키워드로 약품 리스트를 검색하여 반환합니다. + * + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 + * @modified 2025-05-03 + * @since 2025-05-03 + */ + SearchResponseList searchDrugByIngredient(DrugSearchNatural drugSearch); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java index dbf91e6..184101c 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRdbRepositoryPort.java @@ -1,9 +1,5 @@ package com.likelion.backendplus4.yakplus.search.application.port.out; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; /** @@ -13,16 +9,13 @@ * @since 2025-04-30 */ public interface DrugSearchRdbRepositoryPort { - Page findAllDrugs(Pageable pageable); - - /** - * ID를 기반으로 단일 의약품 정보를 조회합니다. - * - * @param id 조회할 의약품의 고유 ID - * @return 조회된 의약품 객체 - * - * @author 함예정 - * @since 2025-04-30 - */ - Drug findById(Long id); + /** + * ID를 기반으로 단일 의약품 정보를 조회합니다. + * + * @param id 조회할 의약품의 고유 ID + * @return 조회된 의약품 객체 + * @author 함예정 + * @since 2025-04-30 + */ + Drug findById(Long id); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java index 6619933..f98bfc0 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/DrugSearchRepositoryPort.java @@ -2,21 +2,104 @@ import java.util.List; -import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; +import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; import org.springframework.data.domain.Page; -import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; public interface DrugSearchRepositoryPort { - List searchByNatural(SearchByNaturalParams searchByNaturalParams); - List getSymptomAutoCompleteResponse(String q); + /** + * 주어진 쿼리 및 임베딩 벡터를 사용해 Elasticsearch에서 검색을 수행하고, + * 도메인 모델 리스트를 반환한다. 실패 시 SearchException을 발생시킨다. + * + * @param drugSearchNatural 검색어 및 페이지 정보를 담은 도메인 객체 + * @param embeddings 검색어에 대한 임베딩 벡터 배열 + * @return 검색된 Drug 도메인 객체 리스트 + * @throws SearchException 검색 처리 중 오류 발생 시 + * @author 정안식 + * @modified 2025-05-03 + * 25.04.27 - 코드 리팩토링 + * 25.05.02 - 헥사고날 구조에 맞추어 코드 리팩토링 + * 25.05.03 - EsIndexName를 외부에 가져오도록 수정 + * @since 2025-04-22 + */ + List searchByNatural(DrugSearchNatural drugSearchNatural, float[] embeddings,String EsIndexName); - Page searchDocsBySymptom(String q, int page, int size); + /** + * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 + * symptomSuggester 필드에서 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트 + * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-04-28 + */ + List getSymptomAutoCompleteResponse(String q); + /** + * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 + * drugNameSuggester 필드에서 약품명 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 키워드 리스트 + * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @since 2025-04-28 + * @modified 2025-04-29 + */ List getDrugNameAutoCompleteResponse(String q); - Page searchDocsByDrugName(String q, int page, int size); + /** + * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 + * ingredientNameSuggester 필드에서 자동완성 추천 단어 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 성분명 리스트 + * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @author 박찬병 + * @since 2025-05-01 + * @modified 2025-05-01 + */ + List getIngredientAutoCompleteResponse(String q); + + /** + * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 증상 문서 리스트 페이지 객체 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-05-03 + * @throws SearchException 검색 중 오류 발생 시 + */ + Page searchDocsBySymptom(DrugSearchNatural request); + + /** + * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 약품명 문서 리스트 페이지 객체 + * @author 박찬병 + * @since 2025-04-28 + * @modified 2025-05-03 + * @throws SearchException 검색 중 오류 발생 시 + */ + Page searchDocsByDrugName(DrugSearchNatural request); + + /** + * 검색어에 매칭되는 성분명을 포함한 약품 문서 리스트를 Elasticsearch에서 조회합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 성분 기반 검색 결과 페이지 객체 + * @author 박찬병 + * @since 2025-05-01 + * @modified 2025-05-03 + * @throws SearchException 검색 중 오류 발생 시 + */ + Page searchDocsByIngredient(DrugSearchNatural request); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java index a50330c..9e20924 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/EmbeddingPort.java @@ -1,5 +1,15 @@ package com.likelion.backendplus4.yakplus.search.application.port.out; public interface EmbeddingPort { + + /** + * 현재 선택된 어댑터로부터 임베딩 벡터를 반환합니다. + * + * @param text 임베딩을 생성할 입력 문자열 + * @return 입력 문자열에 대한 임베딩 벡터 배열 + * @throws IllegalStateException 어댑터가 선택되지 않은 경우 + * @author 정안식 + * @since 2025-05-02 + */ float[] getEmbedding(String text); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByKeywordParams.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByKeywordParams.java new file mode 100644 index 0000000..93c4b36 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByKeywordParams.java @@ -0,0 +1,14 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class SearchByKeywordParams { + private final String query; + private final int from; + private final int size; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java index 55dc847..bcdf02b 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/dto/SearchByNaturalParams.java @@ -2,6 +2,13 @@ import lombok.Getter; +/** + * 자연어 검색을 위한 Elasticsearch 파라미터를 담는 DTO 클래스입니다. + * + * query, vector, from, size 필드를 통해 검색 요청 정보를 정의합니다. + * + * @since 2025-05-03 + */ @Getter public class SearchByNaturalParams { private final String query; @@ -9,6 +16,17 @@ public class SearchByNaturalParams { private final int from; private final int size; + /** + * 주어진 검색어, 임베딩 벡터, 오프셋, 페이지 크기로 파라미터 객체를 생성합니다. + * + * @param query 검색어 문자열 + * @param vector 검색어 임베딩 벡터 (null일 수 없습니다) + * @param from 조회 시작 오프셋 (0 이상) + * @param size 페이지당 결과 개수 (1 이상) + * @throws IllegalArgumentException vector가 null인 경우 발생 + * @author 정안식 + * @since 2025-05-03 + */ public SearchByNaturalParams(String query, float[] vector, int from, int size) { if (vector == null) { throw new IllegalArgumentException("Vector는 null일 수 없습니다."); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByKeywordParamsMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByKeywordParamsMapper.java new file mode 100644 index 0000000..425eb6c --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByKeywordParamsMapper.java @@ -0,0 +1,15 @@ +package com.likelion.backendplus4.yakplus.search.application.port.out.mapper; + +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByKeywordParams; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; + +public class SearchByKeywordParamsMapper { + + public static SearchByKeywordParams toParams(DrugSearchNatural drugSearchNatural) { + return SearchByKeywordParams.builder() + .query(drugSearchNatural.getQuery()) + .size(drugSearchNatural.getSize()) + .from(drugSearchNatural.getPage()) + .build(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java index c1e0cb0..6c92db8 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/port/out/mapper/SearchByNaturalParamsMapper.java @@ -3,7 +3,26 @@ import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; +/** + * 자연어 검색 파라미터 객체로 변환하는 매퍼 클래스 + * + * DrugSearchNatural 도메인 모델과 생성된 임베딩 배열을 + * SearchByNaturalParams DTO로 조립하여 반환합니다. + * + * @since 2025-05-02 + */ public class SearchByNaturalParamsMapper { + + /** + * DrugSearchNatural과 임베딩 벡터를 기반으로 + * SearchByNaturalParams 객체를 생성합니다. + * + * @param natural 검색어, 페이지 정보가 담긴 도메인 모델 + * @param embeddings 검색어에 대한 임베딩 벡터 배열 + * @return 조립된 SearchByNaturalParams DTO + * @author 정안식 + * @since 2025-05-02 + */ public static SearchByNaturalParams toParams(DrugSearchNatural natural, float[] embeddings) { int from = natural.getPage() * natural.getSize(); return new SearchByNaturalParams(natural.getQuery(), embeddings, from, natural.getSize()); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java index a0af9df..039cff3 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/application/service/DrugSearcher.java @@ -1,6 +1,5 @@ package com.likelion.backendplus4.yakplus.search.application.service; -import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRdbRepositoryPort; import com.likelion.backendplus4.yakplus.search.application.port.out.mapper.SearchByNaturalParamsMapper; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; @@ -12,21 +11,18 @@ import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; -import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.request.SearchRequest; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; -import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; import com.likelion.backendplus4.yakplus.search.presentation.mapper.SearchResponseMapper; +import com.likelion.backendplus4.yakplus.switcher.application.port.out.EmbeddingSwitchPort; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.util.List; -import java.util.stream.Collectors; import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; @@ -42,14 +38,15 @@ public class DrugSearcher implements SearchDrugUseCase { private final DrugSearchRepositoryPort drugSearchRepositoryPort; private final EmbeddingPort embeddingPort; + private final EmbeddingSwitchPort embeddingSwitchPort; private final DrugSearchRdbRepositoryPort drugSearchRdbRepositoryPort; /** * 검색어 유효성 검사, 임베딩 생성, ES 검색 수행 후 * 리스트로 매핑하여 반환한다. * - * @param request 검색어 및 페이지 정보 - * @return 검색 결과 DTO 리스트 + * @param drugSearchNatural 검색어 및 페이지 정보를 담은 객체 + * @return 검색 결과 객체 리스트 * @throws SearchException 검색어가 유효하지 않은 경우 발생 * @author 정안식 * @modified 2025-05-02 @@ -57,9 +54,9 @@ public class DrugSearcher implements SearchDrugUseCase { * @since 2025-04-22 */ @Override - public SearchResponseList searchDrugByNatural(DrugSearchNatural DrugSearchNatural) { - log("search() 메서드 호출, 검색어: " + DrugSearchNatural.getQuery()); - return searchDrugs(DrugSearchNatural, generateEmbeddings(DrugSearchNatural.getQuery())); + public SearchResponseList searchDrugByNatural(DrugSearchNatural drugSearchNatural) { + log("search() 메서드 호출, 검색어: " + drugSearchNatural.getQuery()); + return searchDrugs(drugSearchNatural, generateEmbeddings(drugSearchNatural.getQuery())); } /** @@ -80,7 +77,7 @@ private float[] generateEmbeddings(String query) { * 생성된 임베딩 벡터와 검색 요청 정보를 이용해 Elasticsearch에서 조회를 수행하고, * 도메인 모델 리스트를 SearchResponse DTO 리스트로 변환해 반환한다. * - * @param searchRequest 검색어 및 페이지/사이즈 정보가 담긴 DTO + * @param drugSearchNatural 검색어 및 페이지/사이즈 정보가 담긴 객체 * @param embeddings 검색어에 대한 임베딩 벡터 배열 * @return SearchResponse 객체 리스트 * @author 정안식 @@ -90,11 +87,22 @@ private float[] generateEmbeddings(String query) { */ private SearchResponseList searchDrugs(DrugSearchNatural drugSearchNatural, float[] embeddings) { log("searchDrugs() 메서드 호출"); - List drugs = drugSearchRepositoryPort.searchByNatural(SearchByNaturalParamsMapper.toParams(drugSearchNatural, embeddings)); + List drugs = drugSearchRepositoryPort.searchByNatural(drugSearchNatural, embeddings, getEsIndexName()); log("searchDrugs() 메서드 완료, 검색어: " + drugSearchNatural.getQuery() + ", 검색 결과 개수: " + drugs.size()); return SearchResponseMapper.toResponseListWithCount(drugs); } + /** + * Elasticsearch 인덱스 이름을 가져옵니다. + * + * @return Elasticsearch 인덱스 이름 + * @author 정안식 + * @since 2025-05-03 + */ + private String getEsIndexName() { + return embeddingSwitchPort.getAdapterBeanName(); + } + /** * 의약품 ID를 통해 상세 정보를 조회합니다. * @@ -143,49 +151,75 @@ public AutoCompleteStringList getDrugNameAutoComplete(String q) { return new AutoCompleteStringList(drugSearchRepositoryPort.getDrugNameAutoCompleteResponse(q)); } + + + /** + * 사용자 입력 문자열을 바탕으로 성분명 자동완성 추천 키워드를 조회합니다. + * + * Elasticsearch Suggest API를 활용하여 성분명과 관련된 자동완성 키워드 리스트를 반환합니다. + * + * @param q 사용자 입력 문자열 + * @return 자동완성 추천 결과 리스트 DTO (AutoCompleteStringList) + * @author 박찬병 + * @since 2025-05-03 + * @modified 2025-05-03 + */ + @Override + public AutoCompleteStringList getIngredientAutoComplete(String q) { + log("getIngredientAutoComplete() 메서드 호출, 검색어: " + q); + return new AutoCompleteStringList(drugSearchRepositoryPort.getIngredientAutoCompleteResponse(q)); + } + + /** * 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다. * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 - * @param size 페이지 당 문서 수 + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO * @author 박찬병 * @since 2025-04-25 * @modified 2025-04-27 */ - public SearchResponseList searchDrugBySymptom(String q, int page, int size) { - log("searchDrugBySymptom() 메서드 호출, 검색어: " + q); - Page drugPage = drugSearchRepositoryPort.searchDocsBySymptom(q, page, size); - return new SearchResponseList( - drugPage.getContent().stream() - .map(DrugMapper::toResponse) - .toList(), - drugPage.getTotalElements() - ); + @Override + public SearchResponseList searchDrugBySymptom(DrugSearchNatural drugSearch) { + log("searchDrugBySymptom() 메서드 호출, 검색어: " + drugSearch.getQuery()); + Page drugPage = drugSearchRepositoryPort.searchDocsBySymptom(drugSearch); + return SearchResponseMapper.toResponseByKeywordDomain(drugPage); } + /** * 주어진 약품명 키워드로 약품 리스트를 검색하여 반환합니다. * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 - * @param size 페이지 당 조회할 문서 수 + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO * @author 박찬병 * @since 2025-04-29 - * @modified 2025-04-30 + * @modified 2025-05-03 */ @Override - public SearchResponseList searchDrugByDrugName(String q, int page, int size) { - log("searchDrugByDrugName() 메서드 호출, 검색어: " + q); - Page drugPage = drugSearchRepositoryPort.searchDocsByDrugName(q, page, size); - - return new SearchResponseList( - drugPage.getContent().stream() - .map(DrugMapper::toResponse) - .toList(), - drugPage.getTotalElements() - ); + public SearchResponseList searchDrugByDrugName(DrugSearchNatural drugSearch) { + log("searchDrugByDrugName() 메서드 호출, 검색어: " + drugSearch.getQuery()); + Page drugPage = drugSearchRepositoryPort.searchDocsByDrugName(drugSearch); + + return SearchResponseMapper.toResponseByKeywordDomain(drugPage); } + + /** + * 주어진 성분명 키워드로 약품 리스트를 검색하여 반환합니다. + * + * @param drugSearch 검색어 및 페이지/사이즈 정보가 담긴 객체 + * @return 약품 리스트와 총 검색 결과 수를 포함하는 SearchResponseList DTO + * @author 박찬병 + * @since 2025-05-03 + * @modified 2025-05-03 + */ + @Override + public SearchResponseList searchDrugByIngredient(DrugSearchNatural drugSearch) { + log("searchDrugByIngredient() 메서드 호출, 검색어: " + drugSearch.getQuery()); + Page drugPage = drugSearchRepositoryPort.searchDocsByIngredient(drugSearch); + + return SearchResponseMapper.toResponseByKeywordDomain(drugPage); + } + } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java index 0517396..d0bdba4 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/common/exception/error/SearchErrorCode.java @@ -34,11 +34,11 @@ public enum SearchErrorCode implements ErrorCode { INVALID_PAGE(HttpStatus.BAD_REQUEST, 140002, "페이지 번호는 0 이상이어야 합니다."), INVALID_SIZE(HttpStatus.BAD_REQUEST, 140003, "페이지 크기는 1 이상이어야 합니다."), INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 140004, "지원하지 않는 검색 타입입니다."), - ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430002, "Elasticsearch 검색 실패"), - RDB_SEARCH_ERROR(HttpStatus.NO_CONTENT, 430003, "검색 결과가 없습니다"), + ES_SEARCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 430001, "Elasticsearch 검색 실패"), + RDB_SEARCH_ERROR(HttpStatus.NO_CONTENT, 430002, "검색 결과가 없습니다"), EMBEDDING_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 440001, "임베딩 API 호출 실패"), ES_SUGGEST_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "검색어 자동완성에 실패했습니다."), - ES_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440002, "증상 검색에 실패했습니다."); + ES_SEARCH_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, 440003, "증상 검색에 실패했습니다."); private final HttpStatus status; private final int code; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java index 53e8dc4..85f5040 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/config/ElasticsearchConfig.java @@ -4,5 +4,4 @@ @Configuration public class ElasticsearchConfig { - // TODO: 필요 시 RestClient 빈 등록 } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/config/RestTemplateConfig.java b/src/main/java/com/likelion/backendplus4/yakplus/search/config/RestTemplateConfig.java new file mode 100644 index 0000000..b9e8e2b --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/config/RestTemplateConfig.java @@ -0,0 +1,13 @@ +package com.likelion.backendplus4.yakplus.search.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java index 8200b90..d5129d1 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Drug.java @@ -1,6 +1,5 @@ package com.likelion.backendplus4.yakplus.search.domain.model; -import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; import lombok.*; import java.time.LocalDate; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java index dde7931..82cb94c 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomain.java @@ -7,8 +7,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; +/** + * Elasticsearch 검색 결과를 담는 도메인 모델 클래스 + * + * @since 2025-05-03 + */ @Getter -@NoArgsConstructor @AllArgsConstructor @Builder public class DrugSearchDomain { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java index f32e699..f433003 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchDomainNatural.java @@ -7,6 +7,11 @@ import java.util.List; +/** + * 자연어 검색 결과를 나타내는 도메인 모델 클래스 + * + * @since 2025-05-03 + */ @Getter @AllArgsConstructor @Builder diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java index 5f73d82..ebb7eb4 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/DrugSearchNatural.java @@ -6,6 +6,15 @@ import lombok.Builder; import lombok.Getter; import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; + +/** + * 자연어 검색 요청 정보를 담는 도메인 모델 클래스 + * + * query, page, size 필드를 통해 검색 조건을 정의하고, + * 유효성 검증을 수행합니다. + * + * @since 2025-05-02 + */ @Getter @Builder public class DrugSearchNatural { @@ -13,6 +22,17 @@ public class DrugSearchNatural { private final int page; private final int size; + /** + * 주어진 검색 파라미터로 객체를 생성합니다. + * 입력값의 유효성을 검사하여 부적절한 경우 예외를 던집니다. + * + * @param query 검색을 위한 자연어 쿼리 문자열 (null 또는 빈 문자열 불가) + * @param page 조회할 페이지 번호 (0 이상) + * @param size 페이지당 결과 개수 (1 이상) + * @throws SearchException 쿼리가 비어있거나 페이지/사이즈 값이 부적절한 경우 + * @author 정안식 + * @since 2025-05-02 + */ public DrugSearchNatural(String query, int page, int size) { if (query == null || query.isBlank()) { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Material.java similarity index 88% rename from src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Material.java index fab8c35..13915d3 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/vo/Material.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/domain/model/Material.java @@ -1,4 +1,4 @@ -package com.likelion.backendplus4.yakplus.drug.domain.model.vo; +package com.likelion.backendplus4.yakplus.search.domain.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java index 70bbc8f..feb95a8 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/DrugJpaAdapter.java @@ -1,54 +1,95 @@ package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug; -import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRdbRepositoryPort; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa.GovDrugJpaRepository; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.jpa.DrugJpaRepository; import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; - import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * DB에서 약품 데이터를 다루는 어댑터입니다. * * @author 박찬병 - * @since 2025-04-24 + * @fields LEADING_NUMBER_PATTERN 약품 주의사항의 키에서 선행 숫자를 추출하기 위한 정규 표현식 + * @fields drugJpaRepository 약품 정보를 저장하는 JPA 레포지토리 + * @modified 2025-05-03 함예정 + * - RDB 저장된 주의사항을 순서대로 정렬하는 기능 추가 * @modified 2025-04-25 + * @since 2025-04-24 */ @Component @RequiredArgsConstructor public class DrugJpaAdapter implements DrugSearchRdbRepositoryPort { + private static final Pattern LEADING_NUMBER_PATTERN = Pattern.compile("^(\\d{1,2})"); + + private final DrugJpaRepository drugJpaRepository; + + /** + * 약품 정보를 DB에서 조회합니다. + * + * @param id 약품 ID + * @return 약품 정보 + * @throws SearchException 약품을 찾을 수 없는 경우 + * @author 함예정 + * @since 2025-04-30 + */ + @Override + public Drug findById(Long id) { + Drug drug = DrugMapper.toDomainFromEntity( + drugJpaRepository.findById(id).orElseThrow( + () -> new SearchException(SearchErrorCode.RDB_SEARCH_ERROR))); + Map> precaution = drug.getPrecaution(); + if (precaution != null) { + drug.setPrecaution(sortByLeadingNumberIfPresent(precaution)); + } + return drug; + } + + /** + * 약품 주의사항을 선행 숫자에 따라 정렬합니다. + * @param input 약품 주의사항 Map + * @return Map 정렬된 약품 주의사항 Map + * @since 2025-05-03 + * @author 함예정 + */ + private Map> sortByLeadingNumberIfPresent(Map> input) { + return input.entrySet().stream() + .sorted(Comparator.comparingInt(entry -> extractLeadingNumberOrMax(entry.getKey()))) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (e1, e2) -> e1, + LinkedHashMap::new + )); + } - private final GovDrugJpaRepository drugJpaRepository; - - /** - * 주어진 Pageable 정보에 따라 DB에서 한 페이지 분량의 GovDrugEntity를 조회하고, - * 각 엔티티를 도메인 모델(GovDrug)로 변환하여 Page 형태로 반환합니다. - * - * @param pageable 조회할 페이지 번호와 크기를 포함하는 Pageable 객체 - * @return 페이지 단위로 변환된 GovDrug 도메인 객체의 Page - * @author 박찬병 - * @since 2025-04-24 - * @modified 2025-04-25 - * - */ - @Override - public Page findAllDrugs(Pageable pageable) { - return drugJpaRepository.findAll(pageable) - .map(DrugDataMapper::toDomainFromEntity); - } - - @Override - public Drug findById(Long id) { - return DrugMapper.toDomainFromEntity( - drugJpaRepository.findById(id).orElseThrow(() -> new SearchException(SearchErrorCode.RDB_SEARCH_ERROR))); - } + /** + * 약품 주의사항의 키에서 선행 숫자를 추출합니다. + * 만약 숫자가 없으면 Integer.MAX_VALUE를 반환합니다. + * 숫자 포맷에 오류가 발생하면 예외를 무시하고 Integer.MAX_VALUE를 반환합니다. + * + * @param key 약품 주의사항의 키 + * @return 선행 숫자 또는 Integer.MAX_VALUE + */ + private int extractLeadingNumberOrMax(String key) { + Matcher matcher = LEADING_NUMBER_PATTERN.matcher(key); + if (matcher.find()) { + try { + return Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException ignored) {} + } + return Integer.MAX_VALUE; + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index 58ac34c..d9d1228 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -8,13 +8,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.likelion.backendplus4.yakplus.common.util.log.LogLevel; import com.likelion.backendplus4.yakplus.search.application.port.out.DrugSearchRepositoryPort; +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByKeywordParams; import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByNaturalParams; +import com.likelion.backendplus4.yakplus.search.application.port.out.mapper.SearchByKeywordParamsMapper; +import com.likelion.backendplus4.yakplus.search.application.port.out.mapper.SearchByNaturalParamsMapper; import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugKeywordDocument; import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import com.likelion.backendplus4.yakplus.search.infrastructure.support.natural.DrugSearchNaturalMapper; import lombok.RequiredArgsConstructor; @@ -41,7 +44,7 @@ * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. * - * @modified 2025-04-29 + * @modified 2025-05-03 * 25.04.27 - searchBySymptoms() 메서드 리팩토링 * 25.04.29 - 약품명 검색 기능 추가 * @since 2025-04-22 @@ -49,7 +52,6 @@ @Component @RequiredArgsConstructor public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { - private static final String SEARCH_INDEX = "test-gpt"; private final RestClient restClient; private final ObjectMapper objectMapper; @@ -59,28 +61,29 @@ public class ElasticsearchDrugAdapter implements DrugSearchRepositoryPort { * 주어진 쿼리 및 임베딩 벡터를 사용해 Elasticsearch에서 검색을 수행하고, * 도메인 모델 리스트를 반환한다. 실패 시 SearchException을 발생시킨다. * - * @param query 사용자 검색어 - * @param vector 검색어 임베딩 벡터 - * @param size 한 페이지에 조회할 문서 수 - * @param from 조회 시작 오프셋 + * @param drugSearchNatural 검색어 및 페이지 정보를 담은 도메인 객체 + * @param embeddings 검색어에 대한 임베딩 벡터 배열 * @return 검색된 Drug 도메인 객체 리스트 * @throws SearchException 검색 처리 중 오류 발생 시 * @author 정안식 - * @modified 2025-04-27 + * @modified 2025-05-03 * 25.04.27 - 코드 리팩토링 + * 25.05.02 - 헥사고날 구조에 맞추어 코드 리팩토링 + * 25.05.03 - EsIndexName를 외부에 가져오도록 수정 * @since 2025-04-22 */ @Override - public List searchByNatural(SearchByNaturalParams params) { + public List searchByNatural(DrugSearchNatural drugSearchNatural, float[] embeddings, String EsIndexName) { + SearchByNaturalParams params = SearchByNaturalParamsMapper.toParams(drugSearchNatural, embeddings); + log("searchBySymptoms() 메서드 호출, 검색어: " + params.getQuery()); try { - log("searchBySymptoms() 메서드 호출, 검색어: " + params.getQuery()); String esQuery = buildSearchQuery( params.getQuery(), params.getVector(), params.getSize(), params.getFrom() ); - Response response = executeSearch(esQuery); + Response response = executeSearch(esQuery, EsIndexName); return parseSearchResults(response); } catch (Exception e) { @@ -90,8 +93,120 @@ public List searchByNatural(SearchByNaturalParams param } /** - * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 문자열로 조합한다. - * 벡터 유사도와 텍스트 매칭을 결합한 복합 쿼리를 생성한다. + * 증상 자동완성 추천 결과를 조회합니다. + * + * Elasticsearch의 Completion Suggest API를 활용하여 symptomSuggester 필드에서 + * 증상 키워드 자동완성 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @return 추천된 증상 문자열 리스트 + * @throws SearchException 검색 실패 시 예외 발생 + * + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-05-04 + */ + @Override + public List getSymptomAutoCompleteResponse(String q) { + return getAutoCompleteResponse("symptom_dictionary", "symp_sugg", "symptomSuggester", "symptom_autocomplete", q); + } + + /** + * 약품명 자동완성 추천 결과를 조회합니다. + * + * drugNameSuggester 필드에서 Completion Suggest API를 통해 약품명 프리픽스 기반 + * 추천 문자열 리스트를 반환합니다. + * + * @param q 사용자 입력 프리픽스 + * @return 추천된 약품명 리스트 + * @throws SearchException 검색 실패 시 예외 발생 + * + * @author 박찬병 + * @since 2025-04-28 + * @modified 2025-05-04 + */ + @Override + public List getDrugNameAutoCompleteResponse(String q) { + return getAutoCompleteResponse("drug_keyword", "name_sugg", "drugNameSuggester", "drugName_autocomplete", q); + } + + /** + * 성분명 자동완성 추천 결과를 조회합니다. + * + * ingredientNameSuggester 필드를 기반으로 사용자의 입력값에 대한 자동완성 + * 추천 리스트를 반환합니다. + * + * @param q 검색어 프리픽스 + * @return 추천된 성분명 리스트 + * @throws SearchException 검색 실패 시 예외 발생 + * + * @author 박찬병 + * @since 2025-05-01 + * @modified 2025-05-04 + */ + @Override + public List getIngredientAutoCompleteResponse(String q) { + return getAutoCompleteResponse("drug_keyword", "ingr_sugg", "ingredientNameSuggester", "drugName_autocomplete", q); + } + + /** + * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. + * + * match 쿼리를 사용하여 efficacy_list 필드에서 검색을 수행합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 증상 문서 리스트 페이지 객체 + * @throws SearchException 검색 중 오류 발생 시 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-05-04 + */ + @Override + public Page searchDocsBySymptom(DrugSearchNatural request) { + SearchByKeywordParams params = SearchByKeywordParamsMapper.toParams(request); + return searchWithMatch(params, "efficacy_list"); + } + + /** + * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. + * + * match 쿼리를 사용하여 drugName 필드에서 검색을 수행합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 약품명 문서 리스트 페이지 객체 + * @throws SearchException 검색 중 오류 발생 시 + * @author 박찬병 + * @since 2025-04-28 + * @modified 2025-05-04 + */ + @Override + public Page searchDocsByDrugName(DrugSearchNatural request) { + SearchByKeywordParams params = SearchByKeywordParamsMapper.toParams(request); + return searchWithMatchPrefix(params, "drugName"); + } + + /** + * 검색어에 매칭되는 성분명 문서 리스트를 Elasticsearch에서 조회합니다. + * + * match 쿼리를 사용하여 ingredientName 필드에서 검색을 수행합니다. + * + * @param request 사용자가 입력한 자연어 검색 요청 DTO + * @return 성분 기반 검색 결과 페이지 객체 + * @throws SearchException 검색 중 오류 발생 시 + * @author 박찬병 + * @since 2025-05-01 + * @modified 2025-05-04 + */ + @Override + public Page searchDocsByIngredient(DrugSearchNatural request) { + SearchByKeywordParams params = SearchByKeywordParamsMapper.toParams(request); + return searchWithMatch(params, "ingredientName"); + } + + + + /** + * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 JSON 문자열로 조합합니다. * * @param query 검색어 * @param vector 검색어 임베딩 벡터 @@ -151,9 +266,9 @@ private String buildSearchQuery(String query, float[] vector, int size, int from * @modified 2025-04-24 * @since 2025-04-22 */ - private Response executeSearch(String esQuery) throws IOException { + private Response executeSearch(String esQuery, String EsIndexName) throws IOException { log("executeSearch() 메서드 호출"); - Request request = new Request("GET", "/" + SEARCH_INDEX + "/_search"); + Request request = new Request("GET", "/" + EsIndexName + "/_search"); request.setEntity(new NStringEntity(esQuery, ContentType.APPLICATION_JSON)); return restClient.performRequest(request); } @@ -169,7 +284,7 @@ private Response executeSearch(String esQuery) throws IOException { * @modified 2025-05-02 * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 * 25.05.02 - 기존 로직(단순 순환 및 List 매칭) StreamSupport을 사용하도록 개선 - * @since 2025-04-22 + * @since 2025-05-03 */ private List parseSearchResults(Response response) throws IOException { log("parseSearchResults() 메서드 호출"); @@ -185,198 +300,145 @@ private List parseSearchResults(Response response) thro } /** - * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 - * symptomSuggester 필드에서 자동완성 추천 단어 리스트를 반환합니다. + * Completion Suggest API를 사용하여 자동완성 추천 결과를 조회합니다. + * + * 사용자가 입력한 프리픽스(query)를 바탕으로 Elasticsearch Suggest API를 호출하고, + * 지정된 필드에서 자동완성 후보 리스트를 추출합니다. + * + * @param indexName 검색 대상 인덱스 이름 + * @param suggesterKey suggest 응답에서 사용할 key 이름 + * @param fieldName 자동완성 필드명 + * @param analyzer 사용할 분석기(analyzer) 이름 + * @param q 검색어 프리픽스 (사용자 입력값) + * @return 자동완성 추천 문자열 리스트 + * @throws SearchException Elasticsearch 통신 또는 파싱 실패 시 예외 발생 * - * @param q 검색어 프리픽스 - * @return 자동완성 추천 키워드 리스트 - * @throws SearchException 자동완성 API 호출 실패 시 발생 * @author 박찬병 - * @modified 2025-04-28 - * @since 2025-04-24 + * @since 2025-05-04 + * @modified 2025-05-04 */ - @Override - public List getSymptomAutoCompleteResponse(String q) { - log("getSymptomAutoCompleteResponse() 메서드 호출, 검색어: " + q); - SearchResponse resp; + private List getAutoCompleteResponse(String indexName, String suggesterKey, String fieldName, String analyzer, String q) { + log("getAutoCompleteResponse() 호출 - index: " + indexName + ", field: " + fieldName + ", query: " + q); + try { - resp = esClient.search(s -> s - .index("eedoc") + SearchResponse resp = esClient.search(s -> s + .index(indexName) .suggest(su -> su - .suggesters("symp_sugg", sg -> sg + .suggesters(suggesterKey, sg -> sg .prefix(q) .completion(c -> c - .field("symptomSuggester") - .analyzer("symptom_search_autocomplete") // ← 이 줄만 추가 + .field(fieldName) + .analyzer(analyzer) .size(10) ) ) - ) - , Void.class); + ), + Void.class + ); + + return resp.suggest() + .get(suggesterKey) + .getFirst() + .completion() + .options() + .stream() + .map(CompletionSuggestOption::text) + .distinct() + .toList(); + } catch (IOException e) { - log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); + log(LogLevel.ERROR, "Elasticsearch 자동완성 실패: query = " + q, e); throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); } - - // Suggest 파싱 - return resp.suggest().get("symp_sugg") - .get(0).completion().options().stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); } /** - * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. + * match 쿼리를 사용하는 Elasticsearch 검색 메서드입니다. * - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 (0부터 시작) - * @param size 페이지 당 문서 수 - * @return 증상 문서 리스트 - * @throws SearchException 검색 중 오류 발생 시 + * @param request 검색 요청 정보 (쿼리, 페이지, 사이즈) + * @param fieldName 검색 대상 필드명 + * @return 검색 결과 페이지 객체 + * @throws SearchException 검색 중 Elasticsearch 예외 발생 시 * @author 박찬병 - * @modified 2025-04-28 * @since 2025-04-24 + * @modified 2025-05-04 */ - public Page searchDocsBySymptom(String q, int page, int size) { - log("searchDocsBySymptom() 메서드 호출, 검색어: " + q); + private Page searchWithMatch(SearchByKeywordParams request, String fieldName) { + log("searchWithMatch() 호출 - field: " + fieldName + ", query: " + request.getQuery()); try { - SearchResponse resp = esClient.search(s -> s - .index("eedoc") - .from(page * size) - .size(size) + SearchResponse resp = esClient.search(s -> s + .index("drug_keyword") + .from(request.getFrom() * request.getSize()) + .size(request.getSize()) .query(qb -> qb .match(m -> m - .field("efficacy") // only_nouns analyzer 적용된 필드 - .query(q) // 사용자가 입력한 q 값 + .field(fieldName) + .query(request.getQuery()) ) - ) - , DrugSymptomDocument.class); - - List results = resp.hits().hits().stream() - .map(Hit::source) - .filter(Objects::nonNull) - .map(DrugMapper::toDomainBySymptomDocument) - .toList(); + ), + DrugKeywordDocument.class); - long totalHits = resp.hits().total().value(); + return toPageResponse(resp, request); - return new PageImpl<>( - results, - PageRequest.of(page, size), - totalHits - ); } catch (IOException e) { - log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + q, e); + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + request.getQuery(), e); throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); } } /** - * 사용자가 입력한 키워드를 바탕으로 Elasticsearch Completion Suggest API를 호출하여 - * drugNameSuggester 필드에서 약품명 자동완성 추천 단어 리스트를 반환합니다. + * match_phrase_prefix 쿼리를 사용하는 Elasticsearch 검색 메서드입니다. * - * @param q 사용자 입력 문자열 - * @return 자동완성 추천 키워드 리스트 - * @throws SearchException 자동완성 API 호출 실패 시 발생 + * @param request 검색 요청 정보 (쿼리, 페이지, 사이즈) + * @param fieldName 검색 대상 필드명 + * @return 검색 결과 페이지 객체 + * @throws SearchException 검색 중 Elasticsearch 예외 발생 시 * @author 박찬병 - * @modified 2025-04-29 - * @since 2025-04-28 - */ - @Override - public List getDrugNameAutoCompleteResponse(String q) { - log("getDrugNameAutoCompleteResponse() 메서드 호출, 검색어: " + q); - try { - SearchResponse resp = esClient.search(s -> s - .index("drug_name") - .suggest(su -> su - .suggesters("name_sugg", sg -> sg - .prefix(q) - .completion(c -> c - .field("drugNameSuggester") - .analyzer("drugName_autocomplete") - .size(20) - ) - ) - ), - Void.class - ); - var options = resp.suggest().get("name_sugg").get(0).completion().options(); - return options.stream() - .map(CompletionSuggestOption::text) - .distinct() - .toList(); - } catch (IOException e) { - throw new SearchException(SearchErrorCode.ES_SUGGEST_SEARCH_FAIL); - } - } - - /** - * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. - *

- * matchPhrasePrefix 쿼리를 사용하여 약품명 필드에서 접두어 기반 검색을 수행합니다. - * - * @param q 검색어 프리픽스 (사용자 입력) - * @param page 조회할 페이지 번호 (0부터 시작) - * @param size 페이지 당 조회할 문서 수 - * @return 검색된 약품명 문서 리스트를 담은 Page 객체 (DrugSearchDomain) - * @throws SearchException 검색 중 오류 발생 시 - * @modified 2025-04-29 - * @since 2025-04-28 + * @since 2025-04-24 + * @modified 2025-05-04 */ - @Override - public Page searchDocsByDrugName(String q, int page, int size) { - log("searchDocsByItemName() called, query: " + q); + private Page searchWithMatchPrefix(SearchByKeywordParams request, String fieldName) { + log("searchWithMatchPrefix() 호출 - field: " + fieldName + ", query: " + request.getQuery()); try { - SearchResponse resp = esClient.search(s -> s - .index("drug_name") - .from(page * size) - .size(size) + SearchResponse resp = esClient.search(s -> s + .index("drug_keyword") + .from(request.getFrom() * request.getSize()) + .size(request.getSize()) .query(qb -> qb .matchPhrasePrefix(mpp -> mpp - .field("drugName") - .query(q) + .field(fieldName) + .query(request.getQuery()) ) ), - DrugNameDocument.class - ); + DrugKeywordDocument.class); - List results = resp.hits().hits().stream() - .map(Hit::source) - .filter(Objects::nonNull) - .map(DrugMapper::toDomainByNameDocument) - .toList(); - - long totalHits = resp.hits().total().value(); + return toPageResponse(resp, request); - return new PageImpl<>( - results, - PageRequest.of(page, size), - totalHits - ); } catch (IOException e) { + log(LogLevel.ERROR, "Elasticsearch 검색 실패: query = " + request.getQuery(), e); throw new SearchException(SearchErrorCode.ES_SEARCH_FAIL); } } /** - * JSON 배열로 전달된 vector 노드를 float[]로 변환한다. + * Elasticsearch 응답을 Page 형태로 변환합니다. * - * @param vectorNode vectors JSON 배열 노드 - * @return float[] 변환된 벡터 배열 - * @author 정안식 - * @modified 2025-05-02 - * 25.05.02 - 사용하지 않도록 수정 - * @since 2025-04-27 + * @param resp Elasticsearch 응답 객체 + * @param request 검색 요청 정보 + * @return 변환된 페이지 객체 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-05-04 */ - private float[] parseVector(JsonNode vectorNode) { - if (!vectorNode.isArray()) { - return new float[0]; - } - float[] vec = new float[vectorNode.size()]; - for (int i = 0; i < vectorNode.size(); i++) { - vec[i] = vectorNode.get(i).floatValue(); - } - return vec; + private Page toPageResponse(SearchResponse resp, SearchByKeywordParams request) { + List results = resp.hits().hits().stream() + .map(Hit::source) + .filter(Objects::nonNull) + .map(DrugMapper::toDomainByDocument) + .toList(); + + long totalHits = resp.hits().total().value(); + return new PageImpl<>(results, PageRequest.of(request.getFrom(), request.getSize()), totalHits); } + } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KmBertEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KmBertEmbeddingAdapter.java new file mode 100644 index 0000000..971039e --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KmBertEmbeddingAdapter.java @@ -0,0 +1,41 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; + +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.dto.EmbeddingRequestText; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.UriCompBuilder; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; + + +@Component +@RequiredArgsConstructor +public class KmBertEmbeddingAdapter implements EmbeddingPort { + private final UriCompBuilder apiUriCompBuilder; + private final RestTemplate restTemplate; + + @Override + public float[] getEmbedding(String text) { + URI embeddingURI = getEmbeddingURI(); + return getEmbeddingVector(embeddingURI, text); + } + + private URI getEmbeddingURI() { + return apiUriCompBuilder.getUriForKmbertEmbeding(); + } + + private float[] getEmbeddingVector(URI embedUri, String text) { + EmbeddingRequestText embeddingRequestText = new EmbeddingRequestText(); + embeddingRequestText.setText(text); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity request = new HttpEntity<>(embeddingRequestText, headers); + return restTemplate.postForObject(embedUri, request, float[].class); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java new file mode 100644 index 0000000..a94b5bf --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java @@ -0,0 +1,40 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence; + +import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.dto.EmbeddingRequestText; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.UriCompBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +public class KrSBertEmbeddinggAdapter implements EmbeddingPort { + private final UriCompBuilder apiUriCompBuilder; + private final RestTemplate restTemplate; + + @Override + public float[] getEmbedding(String text) { + URI embeddingURI = getEmbeddingURI(); + return getEmbeddingVector(embeddingURI, text); + } + + private URI getEmbeddingURI() { + return apiUriCompBuilder.getUriForKrSbertEmbeding(); + } + + private float[] getEmbeddingVector(URI embedUri, String text) { + EmbeddingRequestText embeddingRequestText = new EmbeddingRequestText(); + embeddingRequestText.setText(text); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity request = new HttpEntity<>(embeddingRequestText, headers); + return restTemplate.postForObject(embedUri, request, float[].class); + } +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java index a287d5d..d0086f6 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/OpenAIEmbeddingAdapter.java @@ -25,7 +25,6 @@ * @modified 2025-04-24 * @since 2025-04-22 */ -@Slf4j @Component @RequiredArgsConstructor public class OpenAIEmbeddingAdapter implements EmbeddingPort { diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugKeywordDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugKeywordDocument.java new file mode 100644 index 0000000..c8aba05 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugKeywordDocument.java @@ -0,0 +1,60 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.CompletionField; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Document(indexName = "drug_keyword") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class DrugKeywordDocument { + + @Id + @Field(type = FieldType.Keyword, name = "drugId") + private Long drugId; + + @Field(type = FieldType.Text, name = "drugName") + private String drugName; + + @Field(type = FieldType.Text, name = "company") + private String company; + + @Field(type = FieldType.Keyword, name = "imageUrl") + private String imageUrl; + + @Field(type = FieldType.Text, name = "efficacy") + private List efficacy; + + @Field(type = FieldType.Text, name = "efficacy_list") + private List efficacyList; + + @Field(type = FieldType.Text, name = "ingredientName") + private List ingredientName; + + @CompletionField( + analyzer = "drugName_autocomplete", + searchAnalyzer = "drugName_autocomplete" + ) + private List drugNameSuggester; + + @CompletionField( + analyzer = "drugName_autocomplete", + searchAnalyzer = "drugName_autocomplete" + ) + private List ingredientNameSuggester; + +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java deleted file mode 100644 index 262e239..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugNameDocument.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document; - -import java.util.List; - -import org.springframework.data.annotation.Id; -import org.springframework.data.elasticsearch.annotations.CompletionField; -import org.springframework.data.elasticsearch.annotations.Document; -import org.springframework.data.elasticsearch.annotations.Field; -import org.springframework.data.elasticsearch.annotations.FieldType; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Document(indexName = "drug_name") -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@JsonIgnoreProperties(ignoreUnknown = true) -public class DrugNameDocument { - - @Id - @Field(type = FieldType.Keyword, name = "drugId") - private Long drugId; - - @Field(type = FieldType.Text, name = "drugName") - private String drugName; - - @Field(type = FieldType.Keyword, name = "company") - private String company; - - @Field(type = FieldType.Text, name = "efficacy") - private List efficacy; - - @Field(type = FieldType.Keyword, name = "imageUrl") - private String imageUrl; - - - @CompletionField( - analyzer = "drugName_autocomplete", - searchAnalyzer = "drugName_autocomplete" - ) - private List drugNameSuggester; -} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java deleted file mode 100644 index 83fa802..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/document/DrugSymptomDocument.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document; - -import java.util.List; - -import org.springframework.data.annotation.Id; -import org.springframework.data.elasticsearch.annotations.CompletionField; -import org.springframework.data.elasticsearch.annotations.Document; -import org.springframework.data.elasticsearch.annotations.Field; -import org.springframework.data.elasticsearch.annotations.FieldType; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Document(indexName = "eedoc") -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@JsonIgnoreProperties(ignoreUnknown = true) -public class DrugSymptomDocument { - - @Id - @Field(type = FieldType.Keyword, name = "ITEM_SEQ") - @JsonProperty("ITEM_SEQ") - private Long drugId; - - @Field(type = FieldType.Text, name = "ITEM_NAME") - @JsonProperty("ITEM_NAME") - private String drugName; - - @Field(type = FieldType.Text, name = "company") - private String company; - - @Field(type = FieldType.Text, name = "efficacy") - private List efficacy; - - @Field(type = FieldType.Keyword, name = "imageUrl") - private String imageUrl; - - @CompletionField(analyzer = "symptom_autocomplete") - private List symptomSuggester; -} - diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/dto/EmbeddingRequestText.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/dto/EmbeddingRequestText.java new file mode 100644 index 0000000..0dad207 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/dto/EmbeddingRequestText.java @@ -0,0 +1,12 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class EmbeddingRequestText { + private String text; +} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/DrugEntity.java similarity index 98% rename from src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/DrugEntity.java index ec768dd..ad05f15 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/GovDrugEntity.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/entity/DrugEntity.java @@ -19,7 +19,7 @@ @NoArgsConstructor @AllArgsConstructor @Table(name="GOV_DRUG_RAW_DATA") -public class GovDrugEntity { +public class DrugEntity { @Id @Column(name="ITEM_SEQ") private Long id; diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/DrugJpaRepository.java similarity index 72% rename from src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/DrugJpaRepository.java index 93c2286..346cc09 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/GovDrugJpaRepository.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/jpa/DrugJpaRepository.java @@ -2,11 +2,11 @@ import org.springframework.data.jpa.repository.JpaRepository; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.DrugEntity; /** * 의약품 정보(GovDrugEntity)에 대한 JPA 레포지토리 인터페이스. * * @since 2025-04-30 */ -public interface GovDrugJpaRepository extends JpaRepository { +public interface DrugJpaRepository extends JpaRepository { } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java index 5d42f43..1d33270 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/DrugMapper.java @@ -3,25 +3,33 @@ import java.util.List; import java.util.Map; +import java.util.Objects; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import com.fasterxml.jackson.databind.ObjectMapper; +import com.likelion.backendplus4.yakplus.search.application.port.out.dto.SearchByKeywordParams; import com.likelion.backendplus4.yakplus.search.domain.model.Drug; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugSymptomDocument; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugNameDocument; -import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.GovDrugEntity; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.document.DrugKeywordDocument; +import com.likelion.backendplus4.yakplus.search.infrastructure.adapter.persistence.entity.DrugEntity; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; + /** * 증상 관련 객체를 다루는 매퍼 클래스입니다. * * @author 박찬병 * @since 2025-04-25 - * @modified 2025-04-29 + * @modified 2025-05-03 */ public class DrugMapper { private static final ObjectMapper objectMapper = new ObjectMapper(); + /** * ES 색인용 Document를 도메인 모델(DrugSymptom)로 변환합니다. * @@ -29,35 +37,43 @@ public class DrugMapper { * @return DrugSymptom 도메인 모델 객체 * @author 박찬병 * @since 2025-04-25 - * @modified 2025-04-25 + * @modified 2025-05-01 */ - public static DrugSearchDomain toDomainBySymptomDocument(DrugSymptomDocument symptomDocument) { + public static DrugSearchDomain toDomainByDocument(DrugKeywordDocument symptomDocument) { return DrugSearchDomain.builder() - .drugId(symptomDocument.getDrugId()) - .drugName(symptomDocument.getDrugName()) - .efficacy(symptomDocument.getEfficacy()) - .company(symptomDocument.getCompany()) - .imageUrl(symptomDocument.getImageUrl()) - .build(); + .drugId(symptomDocument.getDrugId()) + .drugName(symptomDocument.getDrugName()) + .efficacy(symptomDocument.getEfficacy()) + .company(symptomDocument.getCompany()) + .imageUrl(symptomDocument.getImageUrl()) + .build(); } + /** - * ES 색인용 Document를 도메인 모델(DrugSearchDomain)로 변환합니다. + * Elasticsearch 응답을 Page 형태로 변환합니다. * - * @param nameDocument 변환 대상 ES Document 객체 (약품명 전용) - * @return DrugSearchDomain 도메인 모델 객체 + * @param resp Elasticsearch 응답 객체 + * @param request 검색 요청 정보 + * @return 변환된 페이지 객체 + * @author 박찬병 + * @since 2025-04-24 + * @modified 2025-05-04 */ - public static DrugSearchDomain toDomainByNameDocument(DrugNameDocument nameDocument) { - return DrugSearchDomain.builder() - .drugId(nameDocument.getDrugId()) - .drugName(nameDocument.getDrugName()) - .efficacy(nameDocument.getEfficacy()) - .company(nameDocument.getCompany()) - .imageUrl(nameDocument.getImageUrl()) - .build(); + public static Page toPageResponse( + co.elastic.clients.elasticsearch.core.SearchResponse resp, SearchByKeywordParams request) { + List results = resp.hits().hits().stream() + .map(Hit::source) + .filter(Objects::nonNull) + .map(DrugMapper::toDomainByDocument) + .toList(); + + long totalHits = Objects.requireNonNull(resp.hits().total()).value(); + return new PageImpl<>(results, PageRequest.of(request.getFrom(), request.getSize()), totalHits); } + /** * 도메인 모델(DrugSymptom)을 HTTP 응답용 DTO(DrugSymptomResponse)로 변환합니다. * @@ -115,7 +131,7 @@ public static DetailSearchResponse toDetailResponse(Drug d) { * @author 함예정 * @since 2025-04-30 */ - public static Drug toDomainFromEntity(GovDrugEntity d) { + public static Drug toDomainFromEntity(DrugEntity d) { return Drug.builder() .drugId(d.getId()) .drugName(d.getDrugName()) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/UriCompBuilder.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/UriCompBuilder.java new file mode 100644 index 0000000..bf6da0f --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/UriCompBuilder.java @@ -0,0 +1,65 @@ +package com.likelion.backendplus4.yakplus.search.infrastructure.support; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +/*** + * API 요청 URI 객체 생성 빌더 + * + * application.yml에서 주입되는 속성 값으로, + * API HOST, PATH를 확인해 URI 객체를 만듭니다. + * + * @since 2025-04-15 + */ +@Component +public class UriCompBuilder { + private final String API_KM_BERT; + private final String API_KR_SBERT; + + public UriCompBuilder( + @Value("${embed.kmbert}") String API_KM_BERT, + @Value("${embed.krsbert}") String API_KR_SBERT) { + this.API_KM_BERT = API_KM_BERT; + this.API_KR_SBERT = API_KR_SBERT; + } + + + /** + * KmBERT 임베딩 API 요청용 URI를 반환합니다. + * + * @return KmBERT API URI + * @author 함예정 + * @since 2025-04-21 + */ + public URI getUriForKmbertEmbeding() { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(API_KM_BERT) + .port(443) + .path("/predict") + .build(true) + .toUri(); + } + + /** + * KrSBERT 임베딩 API 요청용 URI를 반환합니다. + * + * @return KrSBERT API URI + * @author 함예정 + * @since 2025-04-21 + */ + public URI getUriForKrSbertEmbeding() { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(API_KR_SBERT) + .port(443) + .path("/predict") + .build(true) + .toUri(); + } + +} + diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java index 05cc602..5f4d06f 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/DrugSearchNaturalMapper.java @@ -3,26 +3,32 @@ import com.fasterxml.jackson.databind.JsonNode; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; -import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; +/** + * Elasticsearch 응답의 JsonNode를 DrugSearchDomainNatural 도메인 모델로 변환하는 매퍼 클래스입니다. + * + * @since 2025-05-02 + */ public class DrugSearchNaturalMapper { + /** + * JsonNode의 필드 값을 읽어들여 DrugSearchDomainNatural 객체로 매핑합니다. + * + * @param src JsonNode 형태의 Elasticsearch 응답 _source 데이터 + * @return 매핑된 DrugSearchDomainNatural 도메인 객체 + * @author 정안식 + * @since 2025-05-03 + */ public static DrugSearchDomainNatural toDomainNatural(JsonNode src) { - long id = src.path("drugId").asLong(); - String name = src.path("drugName").asText(); - String company = src.path("company").asText(); - List efficacy = StreamSupport.stream(src.path("efficacy").spliterator(), false) - .map(JsonNode::asText) - .collect(Collectors.toList()); - String imageUrl = src.path("imageUrl").asText(null); - return DrugSearchDomainNatural.builder() - .drugId(id) - .drugName(name) - .company(company) - .efficacy(efficacy) - .imageUrl(imageUrl) + .drugId(src.path("drugId").asLong()) + .drugName(src.path("drugName").asText()) + .company(src.path("company").asText()) + .efficacy(StreamSupport.stream(src.path("efficacy").spliterator(), false) + .map(JsonNode::asText) + .collect(Collectors.toList())) + .imageUrl(src.path("imageUrl").asText(null)) .build(); } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java index 0227f35..af42ce4 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/support/natural/SearchNaturalMapper.java @@ -2,8 +2,27 @@ import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; +/** + * 자연어 검색 요청 정보를 도메인 모델로 변환하는 매퍼 클래스 + * + * 이 매퍼는 컨트롤러에서 전달된 쿼리, 페이지 번호, 페이지 크기 정보를 + * DrugSearchNatural 객체로 조립하여 반환합니다. + * + * @since 2025-05-03 + */ public class SearchNaturalMapper { + /** + * 컨트롤러 파라미터로 전달된 쿼리, 페이지 번호, 페이지 크기를 기반으로 + * DrugSearchNatural 도메인 객체를 생성합니다. + * + * @param query 검색을 위한 자연어 쿼리 문자열 + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 페이지당 결과 개수 + * @return 구성된 DrugSearchNatural 도메인 객체 + * @author 정안식 + * @since 2025-05-02 + */ public static DrugSearchNatural toDrugSearchNatural(String query, int page, int size) { return DrugSearchNatural.builder() .query(query) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java index 201f279..9aaa21a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/DrugController.java @@ -2,7 +2,6 @@ import com.likelion.backendplus4.yakplus.response.ApiResponse; import com.likelion.backendplus4.yakplus.search.application.port.in.SearchDrugUseCase; -import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchNatural; import com.likelion.backendplus4.yakplus.search.infrastructure.support.natural.SearchNaturalMapper; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; @@ -17,7 +16,7 @@ /** * 약품 검색 API 엔드포인트를 제공하는 컨트롤러 클래스 * - * @modified 2025-04-29 + * @modified 2025-05-02 * @since 2025-04-22 */ @RestController @@ -27,12 +26,15 @@ public class DrugController { private final SearchDrugUseCase searchDrugUseCase; /** - * 약품 검색 요청을 처리하여 검색 결과를 반환한다. + * 자연어 기반 검색을 처리합니다. * - * @param SearchRequest 검색어, 페이지 및 페이지 크기를 담은 요청 객체 - * @return 검색 결과 리스트를 포함한 표준 응답 구조 + * @param query 검색 쿼리 문자열 + * @param page 조회할 페이지 번호 (기본값 0) + * @param size 페이지당 결과 개수 (기본값 10) + * @return 검색 결과를 담은 표준 API 응답 * @author 정안식 - * @modified 2025-04-24 + * @modified 2025-05-02 + * - GET 메소드로 변경, 이제 Service에 넘길 때 SearchRequest를 사용하지 않음 * @since 2025-04-22 */ @GetMapping("/search/natural") @@ -47,65 +49,111 @@ public ResponseEntity> searchNatural( } /** - * 사용자 입력 키워드를 바탕으로 자동완성 추천 결과를 조회합니다. - *

- * 검색 타입(type)에 따라 증상 자동완성 또는 약품명 자동완성 결과를 반환합니다. + * 증상 키워드 자동완성 API * - * @param type 자동완성 타입 (symptom 또는 name) - * @param q 검색어 프리픽스 + * @param q 검색어 프리픽스 * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse - * @throws SearchException 지원하지 않는 타입 입력 시 예외 발생 * @author 박찬병 - * @modified 2025-04-29 - * @since 2025-04-24 + * @since 2025-05-03 */ - @GetMapping("/autocomplete/{type}") - public ResponseEntity> autocomplete( - @PathVariable String type, - @RequestParam String q) { - - log("drugController 요청 수신 - type: " + type + ", query: " + q); - - SearchType searchType = SearchType.from(type); + @GetMapping("/autocomplete/symptom") + public ResponseEntity> autocompleteSymptom(@RequestParam String q) { + log("drugController 요청 수신 - symptom autocomplete, query: " + q); + AutoCompleteStringList results = searchDrugUseCase.getSymptomAutoComplete(q); + return ApiResponse.success(results); + } - AutoCompleteStringList results = switch (searchType) { - case SYMPTOM -> searchDrugUseCase.getSymptomAutoComplete(q); - case NAME -> searchDrugUseCase.getDrugNameAutoComplete(q); - }; + /** + * 약품명 자동완성 API + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse + * @author 박찬병 + * @since 2025-05-03 + */ + @GetMapping("/autocomplete/name") + public ResponseEntity> autocompleteDrugName(@RequestParam String q) { + log("drugController 요청 수신 - drug name autocomplete, query: " + q); + AutoCompleteStringList results = searchDrugUseCase.getDrugNameAutoComplete(q); + return ApiResponse.success(results); + } + /** + * 성분명 자동완성 API + * + * @param q 검색어 프리픽스 + * @return 자동완성 추천 키워드 리스트를 감싼 ApiResponse + * @author 박찬병 + * @since 2025-05-03 + */ + @GetMapping("/autocomplete/ingredient") + public ResponseEntity> autocompleteIngredient(@RequestParam String q) { + log("drugController 요청 수신 - ingredient autocomplete, query: " + q); + AutoCompleteStringList results = searchDrugUseCase.getIngredientAutoComplete(q); return ApiResponse.success(results); } /** - * 증상 또는 약품명 검색을 통해 매칭되는 약품 리스트를 조회합니다. + * 증상 기반 키워드 검색 API * - * @param type 검색 타입 (symptom 또는 name) - * @param q 검색어 프리픽스 - * @param page 조회할 페이지 번호 (기본값 0) - * @param size 페이지 당 문서 수 (기본값 10) - * @return 검색 결과를 담은 ApiResponse - * @throws SearchException 지원하지 않는 검색 타입 입력 시 예외 발생 + * @param query 검색 키워드 + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 개수 (기본값 10) + * @return 증상 기반 검색 결과 리스트 * @author 박찬병 - * @modified 2025-04-29 - * @since 2025-04-24 + * @since 2025-05-03 */ - @GetMapping("/search/{type}") - public ResponseEntity> searchDrugs( - @PathVariable String type, - @RequestParam String q, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { + @GetMapping("/search/symptom") + public ResponseEntity> searchBySymptom( + @RequestParam("q") String query, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "10") int size) { + + log("drugController 증상 검색 요청 수신 - query: " + query); + DrugSearchNatural natural = SearchNaturalMapper.toDrugSearchNatural(query, page, size); + return ApiResponse.success(searchDrugUseCase.searchDrugBySymptom(natural)); + } - log("drugController 요청 수신 - type: " + type + ", query: " + q); + /** + * 약품명 기반 키워드 검색 API + * + * @param query 검색 키워드 + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 개수 (기본값 10) + * @return 약품명 기반 검색 결과 리스트 + * @author 박찬병 + * @since 2025-05-03 + */ + @GetMapping("/search/name") + public ResponseEntity> searchByName( + @RequestParam("q") String query, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "10") int size) { - SearchType searchType = SearchType.from(type); + log("drugController 약품명 검색 요청 수신 - query: " + query); + DrugSearchNatural natural = SearchNaturalMapper.toDrugSearchNatural(query, page, size); + return ApiResponse.success(searchDrugUseCase.searchDrugByDrugName(natural)); + } - SearchResponseList results = switch (searchType) { - case SYMPTOM -> searchDrugUseCase.searchDrugBySymptom(q, page, size); - case NAME -> searchDrugUseCase.searchDrugByDrugName(q, page, size); - }; + /** + * 성분명 기반 키워드 검색 API + * + * @param query 검색 키워드 + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 개수 (기본값 10) + * @return 성분명 기반 검색 결과 리스트 + * @author 박찬병 + * @since 2025-05-03 + */ + @GetMapping("/search/ingredient") + public ResponseEntity> searchByIngredient( + @RequestParam("q") String query, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "10") int size) { - return ApiResponse.success(results); + log("drugController 성분 검색 요청 수신 - query: " + query); + DrugSearchNatural natural = SearchNaturalMapper.toDrugSearchNatural(query, page, size); + return ApiResponse.success(searchDrugUseCase.searchDrugByIngredient(natural)); } /** diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java deleted file mode 100644 index cc81953..0000000 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/SearchType.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.likelion.backendplus4.yakplus.search.presentation.controller; - -import com.likelion.backendplus4.yakplus.search.common.exception.SearchException; -import com.likelion.backendplus4.yakplus.search.common.exception.error.SearchErrorCode; - -public enum SearchType { - SYMPTOM, - NAME; - - public static SearchType from(String type) { - try { - return SearchType.valueOf(type.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new SearchException(SearchErrorCode.INVALID_SEARCH_TYPE); - } - } -} diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/docs/DrugControllerDocs.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/docs/DrugControllerDocs.java new file mode 100644 index 0000000..769c929 --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/docs/DrugControllerDocs.java @@ -0,0 +1,68 @@ +package com.likelion.backendplus4.yakplus.search.presentation.controller.docs; + +import com.likelion.backendplus4.yakplus.response.ApiResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.AutoCompleteStringList; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.DetailSearchResponse; +import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +/** + * 약품 검색 API 문서 정의 인터페이스 + * + * @since 2025-05-04 + */ +@Tag(name = "Drug", description = "약품 검색 및 자동완성 API") +public interface DrugControllerDocs { + + @Operation(summary = "자연어 검색", description = "자연어 문장으로 약품을 검색합니다.") + ResponseEntity> searchNatural( + @Parameter(in = ParameterIn.QUERY, description = "검색 쿼리", example = "머리가 아파요") String q, + @Parameter(in = ParameterIn.QUERY, description = "페이지 번호", example = "0") int page, + @Parameter(in = ParameterIn.QUERY, description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "증상 자동완성", description = "증상 키워드 자동완성 추천 리스트를 반환합니다.") + ResponseEntity> autocompleteSymptom( + @Parameter(in = ParameterIn.QUERY, description = "검색어 프리픽스", example = "두통") String q + ); + + @Operation(summary = "약품명 자동완성", description = "약품명 키워드 자동완성을 수행합니다.") + ResponseEntity> autocompleteDrugName( + @Parameter(in = ParameterIn.QUERY, description = "검색어 프리픽스", example = "타이레") String q + ); + + @Operation(summary = "성분명 자동완성", description = "성분명 키워드 자동완성을 수행합니다.") + ResponseEntity> autocompleteIngredient( + @Parameter(in = ParameterIn.QUERY, description = "검색어 프리픽스", example = "아세트") String q + ); + + @Operation(summary = "증상 기반 키워드 검색", description = "증상 키워드로 약품 리스트를 검색합니다.") + ResponseEntity> searchBySymptom( + @Parameter(in = ParameterIn.QUERY, description = "검색 키워드", example = "두통") String q, + @Parameter(in = ParameterIn.QUERY, description = "페이지 번호", example = "0") int page, + @Parameter(in = ParameterIn.QUERY, description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "약품명 기반 키워드 검색", description = "약품명 키워드로 약품 리스트를 검색합니다.") + ResponseEntity> searchByName( + @Parameter(in = ParameterIn.QUERY, description = "검색 키워드", example = "타이레놀") String q, + @Parameter(in = ParameterIn.QUERY, description = "페이지 번호", example = "0") int page, + @Parameter(in = ParameterIn.QUERY, description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "성분명 기반 키워드 검색", description = "성분명 키워드로 약품 리스트를 검색합니다.") + ResponseEntity> searchByIngredient( + @Parameter(in = ParameterIn.QUERY, description = "검색 키워드", example = "아세트아미노펜") String q, + @Parameter(in = ParameterIn.QUERY, description = "페이지 번호", example = "0") int page, + @Parameter(in = ParameterIn.QUERY, description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "의약품 상세 조회", description = "의약품 ID로 상세 정보를 조회합니다.") + ResponseEntity> searchDetail( + @Parameter(in = ParameterIn.PATH, description = "의약품 고유 ID", example = "12345") Long id + ); +} \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java index 26fd420..d7973c1 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/controller/dto/response/DetailSearchResponse.java @@ -1,13 +1,12 @@ package com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response; +import com.likelion.backendplus4.yakplus.search.domain.model.Material; +import lombok.Builder; + import java.time.LocalDate; import java.util.List; import java.util.Map; -import com.likelion.backendplus4.yakplus.drug.domain.model.vo.Material; - -import lombok.Builder; - /** * 검색 결과 정보 DTO * @@ -20,16 +19,16 @@ public record DetailSearchResponse( Long drugId, String drugName, String company, - List efficacy, - LocalDate permitDate, - boolean isGeneral, - List materialInfo, - String storeMethod, - String validTerm, - List usage, + List efficacy, + LocalDate permitDate, + boolean isGeneral, + List materialInfo, + String storeMethod, + String validTerm, + List usage, Map> precaution, String imageUrl, - LocalDate cancelDate, - String cancelName, - boolean isHerbal) { + LocalDate cancelDate, + String cancelName, + boolean isHerbal) { } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java index 2be2231..155bef9 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/presentation/mapper/SearchResponseMapper.java @@ -1,15 +1,31 @@ package com.likelion.backendplus4.yakplus.search.presentation.mapper; -import com.likelion.backendplus4.yakplus.search.domain.model.Drug; +import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomain; import com.likelion.backendplus4.yakplus.search.domain.model.DrugSearchDomainNatural; +import com.likelion.backendplus4.yakplus.search.infrastructure.support.DrugMapper; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponse; import com.likelion.backendplus4.yakplus.search.presentation.controller.dto.response.SearchResponseList; +import org.springframework.data.domain.Page; import java.util.List; -import java.util.stream.Collectors; +/** + * DrugSearchDomainNatural 도메인 모델을 API 응답 DTO(SearchResponse, SearchResponseList)로 매핑하는 유틸 클래스입니다. + * + * @modified 2025-05-04 + * @since 2025-04-22 + */ public class SearchResponseMapper { + /** + * 단일 도메인 객체를 SearchResponse DTO로 변환합니다. + * + * @param dsn 변환할 DrugSearchDomainNatural 도메인 객체 + * @return 변환된 SearchResponse DTO + * @author 정안식 + * @modified 2025-05-03 + * @since 2025-04-22 + */ public static SearchResponse toResponse(DrugSearchDomainNatural dsn) { return SearchResponse.builder() .drugId(dsn.getDrugId()) @@ -20,14 +36,53 @@ public static SearchResponse toResponse(DrugSearchDomainNatural dsn) { .build(); } + /** + * 도메인 객체 리스트를 SearchResponse DTO 리스트로 변환합니다. + * + * @param drugs 변환할 DrugSearchDomainNatural 도메인 객체 리스트 + * @return 변환된 SearchResponse DTO 리스트 + * @author 정안식 + * @modified 2025-05-03 + * @since 2025-04-22 + */ public static List toResponseList(List drugs) { return drugs.stream() .map(SearchResponseMapper::toResponse) - .collect(Collectors.toList()); + .toList(); } + /** + * 도메인 객체 리스트를 SearchResponseList DTO로 변환하고, 전체 결과 개수를 포함합니다. + * + * @param drugs 변환할 DrugSearchDomainNatural 도메인 객체 리스트 + * @return 응답 DTO(SearchResponseList) — 리스트와 총 개수를 포함 + * @author 정안식 + * @modified 2025-05-03 + * @since 2025-04-22 + */ public static SearchResponseList toResponseListWithCount(List drugs) { List responses = toResponseList(drugs); return new SearchResponseList(responses, responses.size()); } + + + /** + * DrugSearchDomain 페이지 객체를 SearchResponseList DTO로 변환합니다. + *

+ * 1) 페이지에서 콘텐츠 리스트를 꺼내고, + * 2) 각각을 DTO로 변환한 후, + * 3) 총 개수와 함께 SearchResponseList로 래핑합니다. + * + * @param page Elasticsearch 검색 결과 페이지 (DrugSearchDomain 기준) + * @return SearchResponseList DTO + * @author 박찬병 + * @since 2025-05-03 + */ + public static SearchResponseList toResponseByKeywordDomain(Page page) { + List content = page.getContent().stream() + .map(DrugMapper::toResponse) + .toList(); + + return new SearchResponseList(content, page.getTotalElements()); + } } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java index 8749196..5b3a5aa 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/EmbeddingRouter.java @@ -6,6 +6,14 @@ import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; +/** + * Embedding 어댑터를 전환하고 조회하는 서비스 구현체 + * + * 주어진 adapterBeanName을 이용해 활성화된 Embedding adapter를 전환하거나, + * 현재 선택된 adapter Bean 이름을 조회합니다. + * + * @since 2025-05-02 + */ @Service public class EmbeddingRouter implements EmbeddingRoutingUseCase { private final EmbeddingSwitchPort switchPort; @@ -14,12 +22,26 @@ public EmbeddingRouter(EmbeddingSwitchPort switchPort) { this.switchPort = switchPort; } + /** + * 지정된 adapter Bean 이름으로 Embedding adapter를 전환합니다. + * + * @param adapterBeanName 전환할 adapter Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ @Override public void switchEmbedding(String adapterBeanName) { log("임베딩 스위치 요청 수신 - 어댑터명: " + adapterBeanName); switchPort.switchTo(adapterBeanName); } + /** + * 현재 활성화된 Embedding adapter Bean 이름을 반환합니다. + * + * @return 현재 adapter Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ @Override public String getAdapterBeanName() { log("현재 선택된 어댑터 빈 이름 요청"); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java index 0e01bf3..3ac7bb0 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/in/EmbeddingRoutingUseCase.java @@ -1,6 +1,22 @@ package com.likelion.backendplus4.yakplus.switcher.application.port.in; public interface EmbeddingRoutingUseCase { + + /** + * 지정된 adapter Bean 이름으로 Embedding adapter를 전환합니다. + * + * @param adapterBeanName 전환할 adapter Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ void switchEmbedding(String adapterBeanName); + + /** + * 현재 활성화된 Embedding adapter Bean 이름을 반환합니다. + * + * @return 현재 adapter Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ String getAdapterBeanName(); } \ No newline at end of file diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java index 75b74bc..dd99b2a 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/application/port/out/EmbeddingSwitchPort.java @@ -1,6 +1,23 @@ package com.likelion.backendplus4.yakplus.switcher.application.port.out; public interface EmbeddingSwitchPort { + + /** + * 지정된 Bean 이름에 해당하는 어댑터로 전환합니다. + * + * @param adapterBeanName 전환할 어댑터 Bean 이름 + * @throws IllegalArgumentException 지원되지 않는 어댑터 이름인 경우 + * @author 정안식 + * @since 2025-05-02 + */ void switchTo(String adapterBeanName); + + /** + * 현재 활성화된 어댑터 Bean 이름을 반환합니다. + * + * @return 활성화된 어댑터 Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ String getAdapterBeanName(); } diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java index 037a019..5538a41 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java @@ -4,6 +4,7 @@ import com.likelion.backendplus4.yakplus.search.application.port.out.EmbeddingPort; import com.likelion.backendplus4.yakplus.switcher.application.port.out.EmbeddingSwitchPort; import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; @@ -11,25 +12,54 @@ import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; +/** + * EmbeddingPort 및 EmbeddingSwitchPort를 구현하여 + * 어댑터 간 전환과 현재 어댑터 조회 기능을 제공하는 컴포넌트 + * + * @since 2025-05-02 + */ @Component("embeddingRouterAdapter") @Primary public class EmbeddingRouterAdapter implements EmbeddingPort, EmbeddingSwitchPort { - private static final String DEFAULT_ADAPTER = "openAIEmbeddingAdapter"; + @Value("${embed.switcher.default-adapter}") + private String DEFAULT_ADAPTER; private final Map adapters; private volatile EmbeddingPort embeddingPort; private volatile String adapterBeanName; + /** + * 모든 EmbeddingPort 구현체를 주입받아 어댑터 라우팅 맵을 초기화합니다. + * + * @param allAdapters 어댑터 빈 이름을 키로 하고 EmbeddingPort 구현체를 값으로 갖는 맵 + * @author 정안식 + * @since 2025-05-02 + */ public EmbeddingRouterAdapter(Map allAdapters) { this.adapters = allAdapters; log("구현체 목록: " + adapters.keySet()); } + /** + * 컴포넌트 초기화 후 기본 어댑터로 전환합니다. + * + * @author 정안식 + * @since 2025-05-02 + */ @PostConstruct public void init() { log("EmbeddingRouterAdapter 초기화 - 어댑터명: " + DEFAULT_ADAPTER); switchTo(DEFAULT_ADAPTER); } + /** + * 현재 선택된 어댑터로부터 임베딩 벡터를 반환합니다. + * + * @param text 임베딩을 생성할 입력 문자열 + * @return 입력 문자열에 대한 임베딩 벡터 배열 + * @throws IllegalStateException 어댑터가 선택되지 않은 경우 + * @author 정안식 + * @since 2025-05-02 + */ @Override public float[] getEmbedding(String text) { if (embeddingPort == null) { @@ -39,6 +69,14 @@ public float[] getEmbedding(String text) { return embeddingPort.getEmbedding(text); } + /** + * 지정된 Bean 이름에 해당하는 어댑터로 전환합니다. + * + * @param adapterBeanName 전환할 어댑터 Bean 이름 + * @throws IllegalArgumentException 지원되지 않는 어댑터 이름인 경우 + * @author 정안식 + * @since 2025-05-02 + */ @Override public void switchTo(String adapterBeanName) { log("어댑터 스위치 시도 - 어댑터명: " + adapterBeanName); @@ -52,6 +90,13 @@ public void switchTo(String adapterBeanName) { log("어댑터 스위치 완료 - 현재 어댑터: " + adapterBeanName); } + /** + * 현재 활성화된 어댑터 Bean 이름을 반환합니다. + * + * @return 활성화된 어댑터 Bean 이름 + * @author 정안식 + * @since 2025-05-02 + */ @Override public String getAdapterBeanName() { log("어댑터 빈 이름 요청 - 현재 선택된 어댑터: " + adapterBeanName); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java index 825b657..6f8602b 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/EmbeddingRouterController.java @@ -7,6 +7,13 @@ import static com.likelion.backendplus4.yakplus.common.util.log.LogUtil.log; +/** + * Embedding 라우팅 어댑터를 전환하고 조회하는 REST 컨트롤러 + * + * 요청에 따라 활성화된 Embedding adapter를 변경하거나 현재 adapter를 확인합니다. + * + * @since 2025-05-02 + */ @RestController @RequestMapping("/switch/embeddings") public class EmbeddingRouterController { @@ -16,6 +23,14 @@ public EmbeddingRouterController(EmbeddingRoutingUseCase routerUseCase) { this.routerUseCase = routerUseCase; } + /** + * 지정된 adapterBeanName에 해당하는 embedding adapter로 전환합니다. + * + * @param adapterBeanName 전환할 adapter Bean 이름 + * @return 어댑터 변경 결과 메시지를 담은 ApiResponse + * @author 정안식 + * @since 2025-05-02 + */ @PostMapping("/switch/{adapterBeanName}") public ResponseEntity> switchAdapter(@PathVariable String adapterBeanName) { log("스위치 대상 인덱스명 : " + adapterBeanName); @@ -23,6 +38,13 @@ public ResponseEntity> switchAdapter(@PathVariable String ad return ApiResponse.success("어댑터 변경됨 - 어댑터명: " + adapterBeanName); } + /** + * 현재 활성화된 embedding adapter Bean 이름을 조회합니다. + * + * @return 현재 adapter Bean 이름을 담은 ApiResponse + * @author 정안식 + * @since 2025-05-02 + */ @GetMapping("/current/adapter") public ResponseEntity> checkCurrentAdapter() { return ApiResponse.success(routerUseCase.getAdapterBeanName()); diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/docs/EmbeddingRouterControllerDocs.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/docs/EmbeddingRouterControllerDocs.java new file mode 100644 index 0000000..985b86d --- /dev/null +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/presentation/controller/docs/EmbeddingRouterControllerDocs.java @@ -0,0 +1,40 @@ +package com.likelion.backendplus4.yakplus.switcher.presentation.controller.docs; + +import com.likelion.backendplus4.yakplus.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +/** + * Embedding 라우팅 어댑터 전환/조회 API 문서 정의 인터페이스 + * + * @since 2025-05-02 + */ +@Tag( + name = "Embedding Router", + description = "어떤 Embedding adapter가 활성화되어 있는지 조회하고 전환하는 API" +) +public interface EmbeddingRouterControllerDocs { + + @Operation( + summary = "Embedding 어댑터 전환", + description = "지정된 adapterBeanName 으로 활성화된 Embedding adapter를 변경합니다." + ) + ResponseEntity> switchAdapter( + @Parameter( + in = ParameterIn.PATH, + description = "전환할 adapter Bean 이름", + example = "KmBertEmbeddingAdapter" + ) + String adapterBeanName + ); + + @Operation( + summary = "현재 활성화된 Embedding 어댑터 조회", + description = "현재 사용 중인 Embedding adapter Bean 이름을 반환합니다." + ) + ResponseEntity> checkCurrentAdapter(); +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5ba6294..adb90b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,5 +30,18 @@ gov: path: detail: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnDtlInq05 img: /1471000/DrugPrdtPrmsnInfoService06/getDrugPrdtPrmsnInq06 + +log: + rolling: + directory: logs + file-name: yakplus-batch.log + pattern: "%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n" + max-history: 30 + total-size-cap: 10MB +embed: + kmbert: embed.techlog.dev + krsbert: embedb.techlog.dev + switcher: + default-adapter: openAIEmbeddingAdapter server: port: 8084 \ No newline at end of file From 646937d5d0429a522d007174750e70cd82565419 Mon Sep 17 00:00:00 2001 From: "Yejeong, Ham" Date: Tue, 6 May 2025 01:32:40 +0900 Subject: [PATCH 23/25] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=83=89=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=96=B4=EB=8C=91=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=98=81=EB=AC=B8=20=EC=86=8C=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/route/adapter/EmbeddingRouterAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java index 5538a41..fc426c6 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/switcher/infrastructure/route/adapter/EmbeddingRouterAdapter.java @@ -100,6 +100,6 @@ public void switchTo(String adapterBeanName) { @Override public String getAdapterBeanName() { log("어댑터 빈 이름 요청 - 현재 선택된 어댑터: " + adapterBeanName); - return adapterBeanName; + return adapterBeanName.toLowerCase(); } } From 817db1071bdc2ab926661fee0ef57a316e50d66f Mon Sep 17 00:00:00 2001 From: JUNG ANSIK Date: Tue, 6 May 2025 16:24:47 +0900 Subject: [PATCH 24/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=9E=90=EC=97=B0=EC=96=B4=20=EA=B2=80=EC=83=89=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ElasticsearchDrugAdapter.java | 108 +++++++++--------- 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java index d9d1228..0c1cf8c 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/ElasticsearchDrugAdapter.java @@ -44,7 +44,7 @@ * Elasticsearch를 통해 Drug 도메인 객체의 검색 기능을 제공하는 어댑터 클래스입니다. * DrugSearchRepositoryPort를 구현하여 Elasticsearch 원격 호출을 캡슐화합니다. * - * @modified 2025-05-03 + * @modified 2025-05-06 * 25.04.27 - searchBySymptoms() 메서드 리팩토링 * 25.04.29 - 약품명 검색 기능 추가 * @since 2025-04-22 @@ -78,7 +78,6 @@ public List searchByNatural(DrugSearchNatural drugSearc log("searchBySymptoms() 메서드 호출, 검색어: " + params.getQuery()); try { String esQuery = buildSearchQuery( - params.getQuery(), params.getVector(), params.getSize(), params.getFrom() @@ -94,17 +93,16 @@ public List searchByNatural(DrugSearchNatural drugSearc /** * 증상 자동완성 추천 결과를 조회합니다. - * + *

* Elasticsearch의 Completion Suggest API를 활용하여 symptomSuggester 필드에서 * 증상 키워드 자동완성 리스트를 반환합니다. * * @param q 검색어 프리픽스 * @return 추천된 증상 문자열 리스트 * @throws SearchException 검색 실패 시 예외 발생 - * * @author 박찬병 - * @since 2025-04-24 * @modified 2025-05-04 + * @since 2025-04-24 */ @Override public List getSymptomAutoCompleteResponse(String q) { @@ -113,17 +111,16 @@ public List getSymptomAutoCompleteResponse(String q) { /** * 약품명 자동완성 추천 결과를 조회합니다. - * + *

* drugNameSuggester 필드에서 Completion Suggest API를 통해 약품명 프리픽스 기반 * 추천 문자열 리스트를 반환합니다. * * @param q 사용자 입력 프리픽스 * @return 추천된 약품명 리스트 * @throws SearchException 검색 실패 시 예외 발생 - * * @author 박찬병 - * @since 2025-04-28 * @modified 2025-05-04 + * @since 2025-04-28 */ @Override public List getDrugNameAutoCompleteResponse(String q) { @@ -132,17 +129,16 @@ public List getDrugNameAutoCompleteResponse(String q) { /** * 성분명 자동완성 추천 결과를 조회합니다. - * + *

* ingredientNameSuggester 필드를 기반으로 사용자의 입력값에 대한 자동완성 * 추천 리스트를 반환합니다. * * @param q 검색어 프리픽스 * @return 추천된 성분명 리스트 * @throws SearchException 검색 실패 시 예외 발생 - * * @author 박찬병 - * @since 2025-05-01 * @modified 2025-05-04 + * @since 2025-05-01 */ @Override public List getIngredientAutoCompleteResponse(String q) { @@ -151,15 +147,15 @@ public List getIngredientAutoCompleteResponse(String q) { /** * 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다. - * + *

* match 쿼리를 사용하여 efficacy_list 필드에서 검색을 수행합니다. * * @param request 사용자가 입력한 자연어 검색 요청 DTO * @return 증상 문서 리스트 페이지 객체 * @throws SearchException 검색 중 오류 발생 시 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-05-04 + * @since 2025-04-24 */ @Override public Page searchDocsBySymptom(DrugSearchNatural request) { @@ -169,15 +165,15 @@ public Page searchDocsBySymptom(DrugSearchNatural request) { /** * 검색어에 매칭되는 약품명 문서 리스트를 Elasticsearch에서 조회합니다. - * + *

* match 쿼리를 사용하여 drugName 필드에서 검색을 수행합니다. * * @param request 사용자가 입력한 자연어 검색 요청 DTO * @return 약품명 문서 리스트 페이지 객체 * @throws SearchException 검색 중 오류 발생 시 * @author 박찬병 - * @since 2025-04-28 * @modified 2025-05-04 + * @since 2025-04-28 */ @Override public Page searchDocsByDrugName(DrugSearchNatural request) { @@ -187,15 +183,15 @@ public Page searchDocsByDrugName(DrugSearchNatural request) { /** * 검색어에 매칭되는 성분명 문서 리스트를 Elasticsearch에서 조회합니다. - * + *

* match 쿼리를 사용하여 ingredientName 필드에서 검색을 수행합니다. * * @param request 사용자가 입력한 자연어 검색 요청 DTO * @return 성분 기반 검색 결과 페이지 객체 * @throws SearchException 검색 중 오류 발생 시 * @author 박찬병 - * @since 2025-05-01 * @modified 2025-05-04 + * @since 2025-05-01 */ @Override public Page searchDocsByIngredient(DrugSearchNatural request) { @@ -204,56 +200,57 @@ public Page searchDocsByIngredient(DrugSearchNatural request) } - /** * 검색 파라미터를 바탕으로 Elasticsearch Query DSL을 JSON 문자열로 조합합니다. * - * @param query 검색어 * @param vector 검색어 임베딩 벡터 * @param size 한 페이지에 조회할 문서 수 * @param from 조회 시작 오프셋 * @return Elasticsearch에 전달할 쿼리 JSON 문자열 * @throws IOException JSON 직렬화 과정에서 오류 발생 시 * @author 정안식 - * @modified 2025-04-27 + * @modified 2025-05-06 * 25.04.27 - 스크립트 필드명을 변경된 Drug 도메인 객체에 맞추어 수정 * - 텍스트 매칭 필드 searchAll → efficacy 로 변경(현재는 약의 효과로만 검색하고 있음) + * 25.05.06 - 벡터 유사도를 기준으로 필터링 및 보너스 계산 로직 적용 + * - 텍스트 매칭 필드에 대한 부스트 제거 및 벡터 유사도 기반으로만 순위 정렬 + * - 유사도 기준에 따라 문서의 점수 계산 (cutoff 및 bonus 적용) * @since 2025-04-22 */ - private String buildSearchQuery(String query, float[] vector, int size, int from) throws IOException { + private String buildSearchQuery(float[] vector, int size, int from) throws IOException { String vectorJson = objectMapper.writeValueAsString(vector); - String escapedQuery = query.replace("\"", "\\\\\""); - return """ + + String painless = + "double v = cosineSimilarity(params.q, 'vector') + 1.0; " + + "if (v < params.cutoff) return 0.0; " + + "double bonus = v >= params.cutoff + 0.15 ? v * params.boostFactor : 0.0; " + + "return v + bonus;"; + + return String.format(""" { "from": %d, "size": %d, + "sort": [ + { "_score": { "order": "desc" } }, + { "drugId": { "order": "asc" } } + ], "query": { - "bool": { - "must": { - "script_score": { - "query": { "match_all": {} }, - "script": { - "inline": "cosineSimilarity(params.queryVector, 'vector') + 1.0", - "lang": "painless", - "params": { "queryVector": %s } - } - } - }, - "should": [ - { - "match": { - "efficacy": { - "query": "%s", - "fuzziness": "AUTO", - "boost": 0.2 - } - } + "script_score": { + "query": { "match_all": {} }, + "script": { + "source": "%s", + "lang": "painless", + "params": { + "q": %s, + "cutoff": 1.15, + "boostFactor": 0.5 } - ] + } } } } - """.formatted(from, size, vectorJson, escapedQuery); + """, from, size, painless.replace("\"", "\\\\\""), vectorJson + ); } /** @@ -301,21 +298,20 @@ private List parseSearchResults(Response response) thro /** * Completion Suggest API를 사용하여 자동완성 추천 결과를 조회합니다. - * + *

* 사용자가 입력한 프리픽스(query)를 바탕으로 Elasticsearch Suggest API를 호출하고, * 지정된 필드에서 자동완성 후보 리스트를 추출합니다. * - * @param indexName 검색 대상 인덱스 이름 - * @param suggesterKey suggest 응답에서 사용할 key 이름 - * @param fieldName 자동완성 필드명 - * @param analyzer 사용할 분석기(analyzer) 이름 - * @param q 검색어 프리픽스 (사용자 입력값) + * @param indexName 검색 대상 인덱스 이름 + * @param suggesterKey suggest 응답에서 사용할 key 이름 + * @param fieldName 자동완성 필드명 + * @param analyzer 사용할 분석기(analyzer) 이름 + * @param q 검색어 프리픽스 (사용자 입력값) * @return 자동완성 추천 문자열 리스트 * @throws SearchException Elasticsearch 통신 또는 파싱 실패 시 예외 발생 - * * @author 박찬병 - * @since 2025-05-04 * @modified 2025-05-04 + * @since 2025-05-04 */ private List getAutoCompleteResponse(String indexName, String suggesterKey, String fieldName, String analyzer, String q) { log("getAutoCompleteResponse() 호출 - index: " + indexName + ", field: " + fieldName + ", query: " + q); @@ -360,8 +356,8 @@ private List getAutoCompleteResponse(String indexName, String suggesterK * @return 검색 결과 페이지 객체 * @throws SearchException 검색 중 Elasticsearch 예외 발생 시 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-05-04 + * @since 2025-04-24 */ private Page searchWithMatch(SearchByKeywordParams request, String fieldName) { log("searchWithMatch() 호출 - field: " + fieldName + ", query: " + request.getQuery()); @@ -394,8 +390,8 @@ private Page searchWithMatch(SearchByKeywordParams request, St * @return 검색 결과 페이지 객체 * @throws SearchException 검색 중 Elasticsearch 예외 발생 시 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-05-04 + * @since 2025-04-24 */ private Page searchWithMatchPrefix(SearchByKeywordParams request, String fieldName) { log("searchWithMatchPrefix() 호출 - field: " + fieldName + ", query: " + request.getQuery()); @@ -427,8 +423,8 @@ private Page searchWithMatchPrefix(SearchByKeywordParams reque * @param request 검색 요청 정보 * @return 변환된 페이지 객체 * @author 박찬병 - * @since 2025-04-24 * @modified 2025-05-04 + * @since 2025-04-24 */ private Page toPageResponse(SearchResponse resp, SearchByKeywordParams request) { List results = resp.hits().hits().stream() From 751f5dba415b635ea21be15edbcdfed6d84c2056 Mon Sep 17 00:00:00 2001 From: HaechangLee <112938092+HaechangLee@users.noreply.github.com> Date: Thu, 8 May 2025 08:57:11 +0900 Subject: [PATCH 25/25] =?UTF-8?q?=F0=9F=90=9B=20=20Fix:=20=EC=9E=84?= =?UTF-8?q?=EB=B2=A0=EB=94=A9=20adapter=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...SBertEmbeddinggAdapter.java => KrSBertEmbeddingAdapter.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/{KrSBertEmbeddinggAdapter.java => KrSBertEmbeddingAdapter.java} (95%) diff --git a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddingAdapter.java similarity index 95% rename from src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java rename to src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddingAdapter.java index a94b5bf..7f16753 100644 --- a/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddinggAdapter.java +++ b/src/main/java/com/likelion/backendplus4/yakplus/search/infrastructure/adapter/persistence/KrSBertEmbeddingAdapter.java @@ -14,7 +14,7 @@ @Component @RequiredArgsConstructor -public class KrSBertEmbeddinggAdapter implements EmbeddingPort { +public class KrSBertEmbeddingAdapter implements EmbeddingPort { private final UriCompBuilder apiUriCompBuilder; private final RestTemplate restTemplate;