From e128e126bee2f765e4ed64c3789268ad231d20e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=98=81?= Date: Thu, 20 Nov 2025 11:42:43 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=ED=98=B8=EC=B6=9C=EB=A7=8C?= =?UTF-8?q?=20=EC=9E=88=EB=8A=94=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EC=9E=91=EC=97=85=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=A4=91...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 - docs/자동차과태료_비교로직_정리.md | 195 ++++++ lib/libgpkiapi_jni_1.5.jar | Bin 58217 -> 0 bytes .../project/api/config/ApiMapperConfig.java | 23 - .../api/config/RestTemplateConfig.java | 16 +- .../api/config/VmisIntegrationConfig.java | 117 ---- .../api/config/properties/VmisProperties.java | 137 +--- .../VehicleInterfaceController.java | 47 +- .../impl/ExternalVehicleInfoServiceImpl.java | 91 --- .../api/internal/client/GovernmentApi.java | 21 - .../internal/client/GovernmentApiClient.java | 627 ------------------ .../api/internal/config/GpkiConfig.java | 20 - .../api/internal/config/OpenApiConfig.java | 27 - .../api/internal/config/PropertiesConfig.java | 10 - .../api/internal/gpki/GpkiService.java | 7 - .../api/internal/gpki/NoopGpkiService.java | 18 - .../api/internal/gpki/RealGpkiService.java | 41 -- .../VmisCarBassMatterInqireService.java | 28 - .../service/VmisCarLedgerFrmbkService.java | 21 - .../internal/service/VmisRequestEnricher.java | 85 --- .../impl/InternalVehicleInfoServiceImpl.java | 213 ------ .../VmisCarBassMatterInqireServiceImpl.java | 88 --- .../impl/VmisCarLedgerFrmbkServiceImpl.java | 90 --- .../api/internal/util/GpkiCryptoUtil.java | 98 --- .../api/internal/util/NewGpkiUtil.java | 382 ----------- .../project/api/internal/util/TxIdUtil.java | 18 - .../mapper/VmisCarBassMatterInqireMapper.java | 2 +- .../mapper/VmisCarLedgerFrmbkMapper.java | 2 +- .../service/ExternalVehicleApiService.java | 11 +- .../api/service/VehicleInfoService.java | 76 --- .../impl/ExternalVehicleApiServiceImpl.java | 60 +- ...VmisCarBassMatterInqireLogServiceImpl.java | 2 +- .../VmisCarLedgerFrmbkLogServiceImpl.java | 2 +- .../util/ExceptionDetailUtil.java | 2 +- .../controller/VehicleInquiryController.java | 44 +- .../service/impl/CarFfnlgTrgtServiceImpl.java | 32 +- .../project/config/ProjectMapperConfig.java | 5 +- src/main/resources/application-dev.yml | 88 +-- src/main/resources/application-local.yml | 86 +-- src/main/resources/application-prd.yml | 88 +-- src/main/resources/application.yml | 1 - .../CarBassMatterInqireMapper_maria.xml | 2 +- .../CarLedgerFrmbkMapper_maria.xml | 2 +- 43 files changed, 275 insertions(+), 2654 deletions(-) create mode 100644 docs/자동차과태료_비교로직_정리.md delete mode 100644 lib/libgpkiapi_jni_1.5.jar delete mode 100644 src/main/java/go/kr/project/api/config/ApiMapperConfig.java delete mode 100644 src/main/java/go/kr/project/api/config/VmisIntegrationConfig.java delete mode 100644 src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleInfoServiceImpl.java delete mode 100644 src/main/java/go/kr/project/api/internal/client/GovernmentApi.java delete mode 100644 src/main/java/go/kr/project/api/internal/client/GovernmentApiClient.java delete mode 100644 src/main/java/go/kr/project/api/internal/config/GpkiConfig.java delete mode 100644 src/main/java/go/kr/project/api/internal/config/OpenApiConfig.java delete mode 100644 src/main/java/go/kr/project/api/internal/config/PropertiesConfig.java delete mode 100644 src/main/java/go/kr/project/api/internal/gpki/GpkiService.java delete mode 100644 src/main/java/go/kr/project/api/internal/gpki/NoopGpkiService.java delete mode 100644 src/main/java/go/kr/project/api/internal/gpki/RealGpkiService.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/VmisCarBassMatterInqireService.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/VmisCarLedgerFrmbkService.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/VmisRequestEnricher.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/impl/InternalVehicleInfoServiceImpl.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/impl/VmisCarBassMatterInqireServiceImpl.java delete mode 100644 src/main/java/go/kr/project/api/internal/service/impl/VmisCarLedgerFrmbkServiceImpl.java delete mode 100644 src/main/java/go/kr/project/api/internal/util/GpkiCryptoUtil.java delete mode 100644 src/main/java/go/kr/project/api/internal/util/NewGpkiUtil.java delete mode 100644 src/main/java/go/kr/project/api/internal/util/TxIdUtil.java rename src/main/java/go/kr/project/api/{internal => }/mapper/VmisCarBassMatterInqireMapper.java (97%) rename src/main/java/go/kr/project/api/{internal => }/mapper/VmisCarLedgerFrmbkMapper.java (95%) rename src/main/java/go/kr/project/api/{external => }/service/ExternalVehicleApiService.java (69%) delete mode 100644 src/main/java/go/kr/project/api/service/VehicleInfoService.java rename src/main/java/go/kr/project/api/{external => }/service/impl/ExternalVehicleApiServiceImpl.java (75%) rename src/main/java/go/kr/project/api/{internal => }/util/ExceptionDetailUtil.java (95%) rename src/main/resources/mybatis/mapper/{api-internal => api}/CarBassMatterInqireMapper_maria.xml (99%) rename src/main/resources/mybatis/mapper/{api-internal => api}/CarLedgerFrmbkMapper_maria.xml (99%) diff --git a/build.gradle b/build.gradle index c831be6..46ddf16 100644 --- a/build.gradle +++ b/build.gradle @@ -140,10 +140,6 @@ dependencies { // 파라미터 바인딩된 SQL 쿼리 로깅을 위한 datasource-proxy implementation 'net.ttddyy:datasource-proxy:1.10.1' - // ===== VMIS 통합 관련 의존성 ===== - // GPKI 암호화 라이브러리 (정부 API 연동) - implementation files('lib/libgpkiapi_jni_1.5.jar') - // ===== 개발 도구 의존성 ===== // Lombok - 반복 코드 생성 도구 (Getter, Setter, Builder 등 자동 생성) compileOnly 'org.projectlombok:lombok' diff --git a/docs/자동차과태료_비교로직_정리.md b/docs/자동차과태료_비교로직_정리.md new file mode 100644 index 0000000..f16eb8c --- /dev/null +++ b/docs/자동차과태료_비교로직_정리.md @@ -0,0 +1,195 @@ +# 자동차 과태료 비교 로직 명세서 + +## 개요 + +자동차 과태료 부과 대상을 검증하기 위한 비교 로직 정의서입니다. + +### 기본 설정 + +- **API 선택**: YML flag 값에 따라 구/신 API 호출 결정 +- **통합 모델**: 구/신 API 응답을 통일된 model object로 처리 + - 구 API: 자동차기본정보 API + - 신 API: 자동차기본정보 API, 자동차등록원부(갑) +- **통합 오브젝트**: 자동차기본정보(구, 신)만 필요 + +### 처리 규칙 + +> **중요**: 순서가 중요함! +> - 조건에 걸리는 순간 다음 차량번호 비교로 진행 +> - 각 비교 로직별로 개별 API 호출 수행 + +--- + +## 비교 로직 상세 + +### 1. 상품용 검증 + +**필요 API**: 자동차등록원부(갑) + +#### API 호출 순서 + +| 순서 | API | 입력 파라미터 | 출력 데이터 | +|------|-----|--------------|-------------| +| 1 | 자동차기본정보 | `차량번호`, `부과일자=검사일` | `차대번호`, `소유자명` | +| 2 | 자동차기본정보 | `1.차대번호`, `부과일자=오늘일자` | `차량번호`, `성명`, `민원인주민번호`, `민원인법정동코드` | +| 3 | 자동차등록원본(갑) | `2.차량번호`, `2.성명`, `2.민원인주민번호`, `2.민원인법정동코드` | 갑부 상세 List | + +#### 비교 조건 + +```java +// 조건 1: 소유자명에 '상품용' 포함 여부 +api.MBER_NM.contains("상품용") + +// 조건 2: 갑부 상세 목록에서 명의이전 이력 확인 +for (LedgerRecord record : 갑부상세List) { + if (record.CHG_YMD >= TB_CAR_FFNLG_TRGT.유효기간만료일 + && record.CHG_YMD <= TB_CAR_FFNLG_TRGT.검사종료일자 + && record.CHANGE_JOB_SE_CODE == "11") { // 11 = 명의이전 코드 + return true; + } +} +``` + +#### 결과 처리 + +- **비고 컬럼**: `"[상품용] 갑부정보"` + +--- + +### 2. 이첩 검증 (이첩-1, 이첩-2 병합 로직) + +**필요 API**: 자동차기본정보 + +#### 부과기준일 결정 + +```java +int dayCnt = TB_CAR_FFNLG_TRGT.DAYCNT; // textFile 일수 + +if (dayCnt > 115) { + // 이첩-2 + 부과기준일 = TB_CAR_FFNLG_TRGT.검사종료일자.plusDays(115); +} else { + // 이첩-1 + 부과기준일 = TB_CAR_FFNLG_TRGT.검사일자; +} +``` + +#### API 호출 + +```java +// 부과기준일 기준으로 자동차기본정보 API 호출 +BasicResponse response = 자동차기본정보API.call(부과기준일, 차량번호); +``` + +#### 법정동코드 비교 로직 (공통) + +```java +/** + * 이첩 조건: 법정동코드 불일치 검증 + * 사용본거지법정동코드 앞 4자리 != 사용자 조직코드 앞 4자리 + */ +private boolean checkTransferCondition_LegalDongMismatch( + BasicResponse.Record basicInfo, + String userId, + String vhclno +) { + String useStrnghldLegaldongCode = basicInfo.getUseStrnghldLegaldongCode(); + + // 1. 법정동코드 유효성 검사 + if (useStrnghldLegaldongCode == null || useStrnghldLegaldongCode.length() < 4) { + log.debug("[이첩] 법정동코드 없음. 차량번호: {}", vhclno); + return false; + } + + // 2. 사용자 정보 조회 + SystemUserVO userInfo = userMapper.selectUser(userId); + if (userInfo == null || userInfo.getOrgCd() == null) { + log.debug("[이첩] 사용자 정보 없음. 사용자ID: {}", userId); + return false; + } + + // 3. 법정동코드 앞 4자리 vs 사용자 조직코드 앞 4자리 비교 + String legalDong4 = useStrnghldLegaldongCode.substring(0, 4); + String userOrgCd = userInfo.getOrgCd(); + String userOrg4 = userOrgCd.length() >= 4 + ? userOrgCd.substring(0, 4) + : userOrgCd; + + // 4. 일치 여부 판단 + if (legalDong4.equals(userOrg4)) { + log.debug("[이첩] 법정동코드 일치. 차량번호: {}, 법정동: {}, 조직: {}", + vhclno, legalDong4, userOrg4); + return false; // 일치하면 이첩 대상 아님 + } + + log.info("[이첩] 법정동코드 불일치! 차량번호: {}, 법정동: {}, 조직: {}", + vhclno, legalDong4, userOrg4); + return true; // 불일치하면 이첩 대상 +} +``` + +#### 결과 처리 + +| 구분 | 조건 | 비고 컬럼 형식 | +|------|------|---------------| +| 이첩-1 | `DAYCNT <= 115` | `"서울시 용산구/ 이경호, 검사일사용본거지, [검사대상, 사용자 조직코드 앞 4자리 및 법정동명]"` | +| 이첩-2 | `DAYCNT > 115` | `"전라남도 순천시 / 김정대, 115일 도래지, [2개의 api 법정동코드 및 법정동명]"` | + +--- + +## 데이터 모델 + +### TB_CAR_FFNLG_TRGT (과태료 대상 테이블) + +| 컬럼명 | 설명 | 용도 | +|--------|------|------| +| 검사일 | 검사 기준일 | API 호출 파라미터 | +| 검사종료일자 | 검사 종료 일자 | 115일 계산 기준 | +| 유효기간만료일 | 유효기간 만료일 | 상품용 갑부 비교 시작일 | +| DAYCNT | textFile 일수 | 이첩-1/2 분기 조건 | +| 비고 | 검증 결과 메시지 | 결과 저장 | + +### 코드 정의 + +| 코드 | 코드값 | 설명 | +|------|--------|------| +| CHANGE_JOB_SE_CODE | 11 | 명의이전 | + +--- + +## 처리 흐름도 + +``` +시작 + │ + ▼ +[차량번호 조회] + │ + ▼ +[1. 상품용 검증] ──(조건 충족)──> [비고 기록] ──> [다음 차량] + │ + │ (조건 미충족) + ▼ +[2. DAYCNT 확인] + │ + ├─ (> 115) ──> [이첩-2: 115일 도래지 기준] + │ + └─ (<= 115) ──> [이첩-1: 검사일 기준] + │ + ▼ +[법정동코드 비교] + │ + ├─ (불일치) ──> [비고 기록] ──> [다음 차량] + │ + └─ (일치) ──> [다음 차량] +``` + +--- + +## 구현 시 주의사항 + +1. **API 호출 순서 준수**: 각 검증 단계별로 필요한 API만 호출 +2. **조건 우선순위**: 상품용 > 이첩 순서로 검증 +3. **조기 종료**: 조건 충족 시 즉시 다음 차량으로 이동 +4. **비고 컬럼**: 각 조건별 정해진 형식으로 기록 +5. **법정동코드 길이 검증**: 최소 4자리 이상 필요 diff --git a/lib/libgpkiapi_jni_1.5.jar b/lib/libgpkiapi_jni_1.5.jar deleted file mode 100644 index 5e3bdea4ffc1799cf3273f71b86a406c176d8eb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58217 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 diff --git a/src/main/java/go/kr/project/api/config/ApiMapperConfig.java b/src/main/java/go/kr/project/api/config/ApiMapperConfig.java deleted file mode 100644 index 5059938..0000000 --- a/src/main/java/go/kr/project/api/config/ApiMapperConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package go.kr.project.api.config; - -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.context.annotation.Configuration; - -/** - * API 전용 MyBatis Mapper 스캔 설정 - * - *

VMIS API 통합 모듈의 Mapper 인터페이스만 스캔합니다.

- * - *
    - *
  • DataSource: egovframework 기본 설정 사용 (DataSourceProxyConfig)
  • - *
  • TransactionManager: egovframework 기본 설정 사용 (EgovConfigTransaction.txManager)
  • - *
  • SqlSessionFactory: MyBatis Spring Boot Starter가 자동 생성
  • - *
  • MapperScan: go.kr.project.api.internal.mapper만 스캔 (API 전용)
  • - *
- * - *

일반 프로젝트의 Mapper는 egovframework 설정에서 별도로 스캔됩니다.

- */ -@Configuration -@MapperScan(basePackages = "go.kr.project.api.internal.mapper") -public class ApiMapperConfig { -} diff --git a/src/main/java/go/kr/project/api/config/RestTemplateConfig.java b/src/main/java/go/kr/project/api/config/RestTemplateConfig.java index 771c537..ab73c83 100644 --- a/src/main/java/go/kr/project/api/config/RestTemplateConfig.java +++ b/src/main/java/go/kr/project/api/config/RestTemplateConfig.java @@ -22,9 +22,7 @@ import java.io.IOException; /** * RestTemplate 설정 클래스 - * VMIS Integration Mode에 따라 다른 설정 적용 - * - Internal Mode: 정부 API 호출용 (빠른 타임아웃) - * - External Mode: 외부 VMIS-interface API 호출용 (여유 있는 타임아웃) + * 외부 VMIS-interface API 호출용 설정 * Apache HttpClient 4 기반 연결 풀 관리 및 타임아웃 설정 */ @Slf4j @@ -36,17 +34,9 @@ public class RestTemplateConfig { @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { - // VMIS Integration Mode에 따라 적절한 설정 선택 - String mode = vmisProperties.getIntegration().getMode(); - VmisProperties.RestTemplateProps.ModeConfig config; + VmisProperties.RestTemplateProps config = vmisProperties.getRestTemplate(); - if ("internal".equalsIgnoreCase(mode)) { - config = vmisProperties.getRestTemplate().getInternal(); - log.info("RestTemplate 설정 - Internal Mode (정부 API 호출용)"); - } else { - config = vmisProperties.getRestTemplate().getExternal(); - log.info("RestTemplate 설정 - External Mode (외부 VMIS-interface API 호출용)"); - } + log.info("RestTemplate 설정 - 외부 VMIS-interface API 호출용"); // 타임아웃 설정 int connectTimeout = config.getTimeout().getConnectTimeoutMillis(); diff --git a/src/main/java/go/kr/project/api/config/VmisIntegrationConfig.java b/src/main/java/go/kr/project/api/config/VmisIntegrationConfig.java deleted file mode 100644 index bc880ab..0000000 --- a/src/main/java/go/kr/project/api/config/VmisIntegrationConfig.java +++ /dev/null @@ -1,117 +0,0 @@ -package go.kr.project.api.config; - -import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.service.VehicleInfoService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * VMIS 통합 설정 및 모니터링 - * - *

이 설정 클래스는 VMIS 통합 모드에 대한 정보를 제공하고, - * 애플리케이션 시작 시 현재 활성화된 모드를 로그로 출력합니다.

- * - *

주요 기능:

- *
    - *
  • 애플리케이션 시작 시 VMIS 통합 모드 출력
  • - *
  • 활성화된 VehicleInfoService 구현체 표시
  • - *
  • 설정 검증 및 경고 메시지 출력
  • - *
- * - *

지원하는 모드:

- *
    - *
  • internal: 내부 VMIS 모듈 직접 호출 (InternalVehicleInfoServiceImpl)
  • - *
  • external: 외부 REST API 호출 (ExternalVehicleInfoServiceImpl)
  • - *
- */ -@Slf4j -@Configuration -@RequiredArgsConstructor -public class VmisIntegrationConfig { - - private final VmisProperties vmisProperties; - - /** - * VMIS 통합 모드 정보 출력 - * - *

애플리케이션 시작 시 현재 설정된 VMIS 통합 모드와 - * 관련 설정 정보를 로그로 출력합니다.

- * - * @param vehicleInfoService 활성화된 VehicleInfoService 구현체 - * @return CommandLineRunner - */ - @Bean - public CommandLineRunner vmisIntegrationModeLogger(VehicleInfoService vehicleInfoService) { - return args -> { - String mode = vmisProperties.getIntegration().getMode(); - String implClass = vehicleInfoService.getClass().getSimpleName(); - - log.info("========================================"); - log.info("VMIS Integration Mode: {}", mode); - log.info("Active Implementation: {}", implClass); - log.info("========================================"); - - if ("internal".equalsIgnoreCase(mode)) { - logInternalModeInfo(); - } else if ("external".equalsIgnoreCase(mode)) { - logExternalModeInfo(); - } else { - log.warn("알 수 없는 VMIS 통합 모드: {}. 'internal' 또는 'external'을 사용하세요.", mode); - } - }; - } - - /** - * Internal 모드 설정 정보 출력 - */ - private void logInternalModeInfo() { - log.info("[Internal Mode] 내부 VMIS 모듈을 직접 사용합니다"); - log.info(" - 정부 API 호스트: {}://{}", - vmisProperties.getGov().getScheme(), - vmisProperties.getGov().getHost()); - log.info(" - 기본사항 조회 경로: {}", - vmisProperties.getGov().getServices().getBasic().getPath()); - log.info(" - 등록원부 조회 경로: {}", - vmisProperties.getGov().getServices().getLedger().getPath()); - log.info(" - GPKI 암호화: {}", - vmisProperties.getGpki().getEnabled()); - log.info(" - 연결 타임아웃: {}ms", - vmisProperties.getRestTemplate().getInternal().getTimeout().getConnectTimeoutMillis()); - log.info(" - 읽기 타임아웃: {}ms", - vmisProperties.getRestTemplate().getInternal().getTimeout().getReadTimeoutMillis()); - log.info(" - Rate Limit: 초당 {} 건", - vmisProperties.getRestTemplate().getInternal().getRateLimit().getPermitsPerSecond()); - - if ("Y".equalsIgnoreCase(vmisProperties.getGpki().getEnabled())) { - log.info(" - GPKI 인증서 서버 ID: {}", - vmisProperties.getGpki().getCertServerId()); - log.info(" - GPKI 대상 서버 ID: {}", - vmisProperties.getGpki().getTargetServerId()); - } else { - log.warn(" - GPKI 암호화가 비활성화되어 있습니다. 개발 환경에서만 사용하세요."); - } - } - - /** - * External 모드 설정 정보 출력 - */ - private void logExternalModeInfo() { - log.info("[External Mode] 외부 REST API를 사용합니다"); - log.info(" - 외부 API Base URL: {}", - vmisProperties.getExternal().getApi().getUrl().getBase()); - log.info(" - 연결 타임아웃: {}ms", - vmisProperties.getRestTemplate().getExternal().getTimeout().getConnectTimeoutMillis()); - log.info(" - 읽기 타임아웃: {}ms", - vmisProperties.getRestTemplate().getExternal().getTimeout().getReadTimeoutMillis()); - log.info(" - Rate Limit: 초당 {} 건", - vmisProperties.getRestTemplate().getExternal().getRateLimit().getPermitsPerSecond()); - log.warn(" - 외부 VMIS-interface 서버가 실행 중이어야 합니다."); - log.info(" - 기본사항 조회: POST {}", - vmisProperties.getExternal().getApi().getUrl().buildBasicUrl()); - log.info(" - 등록원부 조회: POST {}", - vmisProperties.getExternal().getApi().getUrl().buildLedgerUrl()); - } -} diff --git a/src/main/java/go/kr/project/api/config/properties/VmisProperties.java b/src/main/java/go/kr/project/api/config/properties/VmisProperties.java index 499bad6..138e65a 100644 --- a/src/main/java/go/kr/project/api/config/properties/VmisProperties.java +++ b/src/main/java/go/kr/project/api/config/properties/VmisProperties.java @@ -2,35 +2,23 @@ package go.kr.project.api.config.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data +@Component @ConfigurationProperties(prefix = "vmis") @Validated public class VmisProperties { - @NotNull - private IntegrationProps integration = new IntegrationProps(); @NotNull private RestTemplateProps restTemplate = new RestTemplateProps(); @NotNull - private SystemProps system = new SystemProps(); - @NotNull - private GpkiProps gpki = new GpkiProps(); - @NotNull - private GovProps gov = new GovProps(); - @NotNull private ExternalProps external = new ExternalProps(); - @Data - public static class IntegrationProps { - @NotBlank - private String mode = "external"; - } - @Data public static class ExternalProps { @NotNull @@ -50,7 +38,7 @@ public class VmisProperties { @NotBlank private String ledger = "/ledger"; // 자동차등록원부 엔드포인트 경로 - // 중요: 외부 VMIS-interface 호출용 전체 URL 조합 헬퍼 (공통 RestTemplate 타임아웃 설정 사용) + // 외부 VMIS-interface 호출용 전체 URL 조합 헬퍼 public String buildBasicUrl() { return join(base, basic); } public String buildLedgerUrl() { return join(base, ledger); } @@ -69,125 +57,30 @@ public class VmisProperties { } } - @Data - public static class SystemProps { - @NotBlank - private String infoSysId; - /** INFO_SYS_IP */ - private String infoSysIp; - /** 시군구코드 (SIGUNGU_CODE) */ - private String sigunguCode; - private String departmentCode; - // 담당자 정보 - private String chargerId; - private String chargerIp; - private String chargerNm; - } - - @Data - public static class GpkiProps { - /** "Y" 또는 "N" */ - @NotBlank - private String enabled = "N"; - private boolean useSign = true; - @NotBlank - private String charset = "UTF-8"; - @NotBlank - private String certServerId; - @NotBlank - private String targetServerId; - // Optional advanced config for native GPKI util - private Boolean ldap; // null -> util default - private String gpkiLicPath; // e.g., C:/gpki2/gpkisecureweb/conf - private String certFilePath; // directory for target cert files when LDAP=false - private String envCertFilePathName; // ..._env.cer - private String envPrivateKeyFilePathName; // ..._env.key - private String envPrivateKeyPasswd; - private String sigCertFilePathName; // ..._sig.cer - private String sigPrivateKeyFilePathName; // ..._sig.key - private String sigPrivateKeyPasswd; - - public boolean isEnabledFlag() { return "Y".equalsIgnoreCase(enabled); } - } - @Data public static class RestTemplateProps { @NotNull - private ModeConfig internal = new ModeConfig(); + private TimeoutConfig timeout = new TimeoutConfig(); @NotNull - private ModeConfig external = new ModeConfig(); - - @Data - public static class ModeConfig { - @NotNull - private TimeoutConfig timeout = new TimeoutConfig(); - @NotNull - private ConnectionPoolConfig connectionPool = new ConnectionPoolConfig(); - @NotNull - private RateLimitConfig rateLimit = new RateLimitConfig(); - - @Data - public static class TimeoutConfig { - private int connectTimeoutMillis = 10000; - private int readTimeoutMillis = 12000; - } - - @Data - public static class ConnectionPoolConfig { - private int maxTotal = 100; - private int maxPerRoute = 20; - } - - @Data - public static class RateLimitConfig { - private double permitsPerSecond = 5.0; - } - } - } - - @Data - public static class GovProps { - @NotBlank - private String scheme = "http"; - @NotBlank - private String host; - @NotBlank - private String basePath; + private ConnectionPoolConfig connectionPool = new ConnectionPoolConfig(); @NotNull - private Services services = new Services(); + private RateLimitConfig rateLimit = new RateLimitConfig(); - public String buildServiceUrl(String path) { - StringBuilder sb = new StringBuilder(); - sb.append(scheme).append("://").append(host); - if (basePath != null && !basePath.isEmpty()) { - if (!basePath.startsWith("/")) sb.append('/'); - sb.append(basePath); - } - if (path != null && !path.isEmpty()) { - if (!path.startsWith("/")) sb.append('/'); - sb.append(path); - } - return sb.toString(); + @Data + public static class TimeoutConfig { + private int connectTimeoutMillis = 10000; + private int readTimeoutMillis = 12000; } @Data - public static class Services { - @NotNull - private Service basic = new Service(); - @NotNull - private Service ledger = new Service(); + public static class ConnectionPoolConfig { + private int maxTotal = 100; + private int maxPerRoute = 20; } @Data - public static class Service { - @NotBlank - private String path; - @NotBlank - private String cntcInfoCode; - @NotBlank - private String apiKey; - @NotBlank - private String cvmisApikey; + public static class RateLimitConfig { + private double permitsPerSecond = 5.0; } } } diff --git a/src/main/java/go/kr/project/api/controller/VehicleInterfaceController.java b/src/main/java/go/kr/project/api/controller/VehicleInterfaceController.java index 011d5e2..00ec930 100644 --- a/src/main/java/go/kr/project/api/controller/VehicleInterfaceController.java +++ b/src/main/java/go/kr/project/api/controller/VehicleInterfaceController.java @@ -1,12 +1,11 @@ package go.kr.project.api.controller; import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.VehicleApiResponseVO; import go.kr.project.api.model.request.BasicRequest; import go.kr.project.api.model.request.LedgerRequest; import go.kr.project.api.model.response.BasicResponse; import go.kr.project.api.model.response.LedgerResponse; -import go.kr.project.api.service.VehicleInfoService; +import go.kr.project.api.service.ExternalVehicleApiService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -47,45 +46,10 @@ import java.util.Collections; @RequestMapping("/api/v1/vehicles") @RequiredArgsConstructor @Slf4j -@Tag(name = "VMIS 차량정보 (Swagger)", description = "vmis.integration.mode에 따라 내부/외부 분기 호출") +@Tag(name = "VMIS 차량정보 (Swagger)", description = "외부 호출") public class VehicleInterfaceController { - private final VehicleInfoService vehicleInfoService; // 모드별 구현체 자동 주입 - - /** - * 자동차 기본정보 + 등록원부(갑) 통합 조회 - * - Internal/External 공통 진입점 (VehicleInfoService 사용) - * - 요청은 Envelope 형식으로 VHRNO(차량번호) 및 추가 파라미터 포함 - */ - @PostMapping(value = "/info.ajax", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "자동차 통합 조회 (기본+등록원부)", - description = "vmis.integration.mode 값에 따라 내부 모듈 또는 외부 REST API를 통해 통합 조회 수행", - requestBody = @RequestBody( - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject( - name = "통합 조회 예제", - value = "{\"data\": [{\"VHRNO\": \"12가3456\",\"LEVY_STDDE\": \"20250101\"}]}" - ) - ) - ) - ) - public ResponseEntity> info( - @org.springframework.web.bind.annotation.RequestBody Envelope envelope - ) { - // 중요 로직: Swagger 요청 Envelope에서 BasicRequest 추출 (차량번호 및 필수 파라미터 포함) - BasicRequest request = (envelope != null && !envelope.getData().isEmpty()) ? envelope.getData().get(0) : null; - if (request == null || request.getVhrno() == null || request.getVhrno().trim().isEmpty()) { - // 간단한 검증 실패 시 빈 데이터로 반환 - return ResponseEntity.ok(new Envelope<>(Collections.emptyList())); - } - - // VehicleInfoService는 모드에 따라 구현체가 자동 주입됨 - VehicleApiResponseVO resp = vehicleInfoService.getVehicleInfo(request); - Envelope out = new Envelope<>(resp); - return ResponseEntity.ok(out); - } + private final ExternalVehicleApiService service; /** * 자동차 기본사항만 조회 @@ -115,7 +79,7 @@ public class VehicleInterfaceController { } // VehicleInfoService는 모드에 따라 구현체가 자동 주입되어 분기 처리 - BasicResponse basic = vehicleInfoService.getBasicInfo(request); + BasicResponse basic = service.getBasicInfo(request); Envelope out = (basic != null) ? new Envelope<>(basic) : new Envelope<>(Collections.emptyList()); return ResponseEntity.ok(out); } @@ -147,8 +111,7 @@ public class VehicleInterfaceController { return ResponseEntity.ok(new Envelope<>(Collections.emptyList())); } - // VehicleInfoService는 모드에 따라 구현체가 자동 주입되어 분기 처리 - LedgerResponse ledger = vehicleInfoService.getLedgerInfo(request); + LedgerResponse ledger = service.getLedgerInfo(request); Envelope out = (ledger != null) ? new Envelope<>(ledger) : new Envelope<>(Collections.emptyList()); return ResponseEntity.ok(out); } diff --git a/src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleInfoServiceImpl.java b/src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleInfoServiceImpl.java deleted file mode 100644 index 0b87a98..0000000 --- a/src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleInfoServiceImpl.java +++ /dev/null @@ -1,91 +0,0 @@ -package go.kr.project.api.external.service.impl; - -import go.kr.project.api.external.service.ExternalVehicleApiService; -import go.kr.project.api.model.VehicleApiResponseVO; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.LedgerResponse; -import go.kr.project.api.service.VehicleInfoService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Service; - -/** - * 외부 REST API를 호출하는 차량 정보 조회 서비스 구현체 - * - *

이 구현체는 외부 VMIS-interface 서버의 REST API를 호출하여 - * 차량 정보를 조회합니다. 기존 ExternalVehicleApiService를 그대로 활용합니다.

- * - *

활성화 조건:

- *
- * # application.yml
- * vmis:
- *   integration:
- *     mode: external
- * 
- * - *

처리 흐름:

- *
    - *
  1. 기존 ExternalVehicleApiService에 요청 위임
  2. - *
  3. ExternalVehicleApiService가 외부 REST API 호출
  4. - *
  5. 결과를 그대로 반환
  6. - *
- * - *

외부 API 서버:

- *
    - *
  • 서버 URL: vmis.external.api.url 설정값 사용
  • - *
  • 기본값: http://localhost:8081/api/v1/vehicles
  • - *
  • 별도의 VMIS-interface 프로젝트가 실행 중이어야 함
  • - *
- * - * @see VehicleInfoService - * @see ExternalVehicleApiService - */ -@Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnProperty(name = "vmis.integration.mode", havingValue = "external", matchIfMissing = true) -public class ExternalVehicleInfoServiceImpl extends EgovAbstractServiceImpl implements VehicleInfoService { - - private final ExternalVehicleApiService externalVehicleApiService; - - @Override - public VehicleApiResponseVO getVehicleInfo(BasicRequest basicRequest) { - String vehicleNumber = basicRequest.getVhrno(); - log.info("[External Mode] 차량 정보 조회 시작 - 차량번호: {}, 부과기준일: {}, 조회구분: {}", - vehicleNumber, basicRequest.getLevyStdde(), basicRequest.getInqireSeCode()); - - VehicleApiResponseVO response = externalVehicleApiService.getVehicleInfo(basicRequest); - - if (response.isSuccess()) { - log.info("[External Mode] 차량번호 {} 조회 성공", vehicleNumber); - } else { - log.warn("[External Mode] 차량번호 {} 조회 실패 - {}", vehicleNumber, response.getMessage()); - } - - return response; - } - - /** - * 외부 REST - 기본정보 단독 조회 - * 중요 로직: 외부 API 호출은 ExternalVehicleApiService에 위임 (BasicRequest 전체 전달) - */ - @Override - public BasicResponse getBasicInfo(BasicRequest request) { - // 중요 로직: 외부 API 호출은 ExternalVehicleApiService에 위임 - return externalVehicleApiService.getBasicInfo(request); - } - - /** - * 외부 REST - 등록원부 단독 조회 - * 중요 로직: 등록원부 조회는 LedgerRequest 전체를 받아서 외부 API에 전달 - */ - @Override - public LedgerResponse getLedgerInfo(LedgerRequest request) { - // 중요 로직: 외부 API 호출은 ExternalVehicleApiService에 위임 - return externalVehicleApiService.getLedgerInfo(request); - } -} diff --git a/src/main/java/go/kr/project/api/internal/client/GovernmentApi.java b/src/main/java/go/kr/project/api/internal/client/GovernmentApi.java deleted file mode 100644 index 0a7199f..0000000 --- a/src/main/java/go/kr/project/api/internal/client/GovernmentApi.java +++ /dev/null @@ -1,21 +0,0 @@ -package go.kr.project.api.internal.client; - -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.LedgerResponse; -import org.springframework.http.ResponseEntity; - -/** - * 정부 시스템 연계 API 추상화 인터페이스. - * - *

외부 정부 시스템과의 통신 계약을 명확히 하여 테스트 용이성과 - * 추후 교체 가능성을 높입니다.

- */ -public interface GovernmentApi { - - ResponseEntity> callBasic(Envelope envelope); - - ResponseEntity> callLedger(Envelope envelope); -} diff --git a/src/main/java/go/kr/project/api/internal/client/GovernmentApiClient.java b/src/main/java/go/kr/project/api/internal/client/GovernmentApiClient.java deleted file mode 100644 index c753bb5..0000000 --- a/src/main/java/go/kr/project/api/internal/client/GovernmentApiClient.java +++ /dev/null @@ -1,627 +0,0 @@ -package go.kr.project.api.internal.client; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.internal.gpki.GpkiService; -import go.kr.project.api.internal.util.TxIdUtil; -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.LedgerResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.HttpStatusCodeException; -import org.springframework.web.client.RestTemplate; - -import java.nio.charset.StandardCharsets; - -/** - * 정부 시스템 API 클라이언트 - * - *

이 클래스는 시군구연계 자동차 정보 조회를 위해 정부 시스템의 API를 호출하는 - * 클라이언트 역할을 수행합니다. HTTP 통신, 암호화, 에러 처리 등 정부 API와의 - * 모든 상호작용을 캡슐화합니다.

- * - *

주요 책임:

- *
    - *
  • 정부 API 엔드포인트로 HTTP 요청 전송
  • - *
  • GPKI(행정전자서명) 암호화/복호화 처리
  • - *
  • 필수 HTTP 헤더 구성 및 관리
  • - *
  • 요청/응답 데이터의 JSON 직렬화/역직렬화
  • - *
  • 트랜잭션 ID(tx_id) 생성 및 추적
  • - *
  • 네트워크 오류 및 HTTP 에러 처리
  • - *
  • 상세한 로깅을 통한 디버깅 지원
  • - *
- * - *

아키텍처 패턴:

- *
    - *
  • Adapter 패턴: 외부 정부 시스템 API를 내부 인터페이스로 변환
  • - *
  • Template Method 패턴: callModel 메서드가 공통 흐름을 정의
  • - *
  • Dependency Injection: 생성자를 통한 의존성 주입
  • - *
- * - *

보안 특성:

- *
    - *
  • GPKI 암호화를 통한 데이터 보안 (선택적 활성화)
  • - *
  • API 키 기반 인증
  • - *
  • 기관 식별 정보(INFO_SYS_ID, REGION_CODE 등)를 헤더에 포함
  • - *
- * - * @see RestTemplate - * @see GpkiService - * @see VmisProperties - */ -@Slf4j -@RequiredArgsConstructor -@Component -public class GovernmentApiClient implements GovernmentApi { - - /** - * Spring RestTemplate (통합 RestTemplate 사용) - * - *

HTTP 클라이언트로서 실제 네트워크 통신을 수행합니다. - * 이 객체는 Spring Bean으로 주입되며, 설정에 따라 다음을 포함할 수 있습니다:

- *
    - *
  • Connection Timeout 설정
  • - *
  • Read Timeout 설정
  • - *
  • Connection Pool 관리
  • - *
  • Rate Limiting (초당 요청 수 제한)
  • - *
  • 메시지 컨버터 (Jackson for JSON)
  • - *
  • 인터셉터 (로깅, 헤더 추가 등)
  • - *
- */ - private final RestTemplate restTemplate; - - /** - * VMIS 설정 속성 - * - *

application.yml 또는 application.properties에서 로드된 설정값들입니다. - * 포함되는 주요 설정:

- *
    - *
  • 정부 API URL (호스트, 포트, 경로)
  • - *
  • API 키 및 인증 정보
  • - *
  • 시스템 식별 정보 (INFO_SYS_ID, REGION_CODE 등)
  • - *
  • GPKI 설정 (인증서 서버 ID 등)
  • - *
- */ - private final VmisProperties props; - - /** - * GPKI(행정전자서명) 서비스 - * - *

정부24 등 공공기관 간 통신에 사용되는 암호화 서비스입니다. - * 주요 기능:

- *
    - *
  • 요청 데이터 암호화 (공개키 암호화)
  • - *
  • 응답 데이터 복호화 (개인키 복호화)
  • - *
  • 전자서명 생성 및 검증
  • - *
  • 암호화 활성화 여부 확인
  • - *
- * - *

암호화가 비활성화된 경우 평문(Plain Text)으로 통신합니다.

- */ - private final GpkiService gpkiService; - - /** - * Jackson ObjectMapper - * - *

Java 객체와 JSON 문자열 간의 변환을 담당합니다. - * 주요 역할:

- *
    - *
  • 요청 객체를 JSON 문자열로 직렬화 (Serialization)
  • - *
  • 응답 JSON을 Java 객체로 역직렬화 (Deserialization)
  • - *
  • 제네릭 타입 처리 (TypeReference 사용)
  • - *
  • 날짜/시간 포맷 변환
  • - *
  • null 값 처리
  • - *
- * - *

Spring Boot가 자동 구성한 ObjectMapper를 주입받아 사용하므로 - * 전역 설정(날짜 포맷, 네이밍 전략 등)이 일관되게 적용됩니다.

- */ - private final ObjectMapper objectMapper; - - - /** - * 서비스 타입 열거형 - * - *

정부 API 서비스의 종류를 구분하는 열거형입니다. - * 각 서비스 타입은 서로 다른 엔드포인트와 API 키를 가집니다.

- * - *

서비스 타입:

- *
    - *
  • BASIC: 자동차 기본사항 조회 서비스 - *
      - *
    • 차량번호로 기본 정보(소유자, 차종, 용도 등) 조회
    • - *
    • 비교적 간단한 정보 제공
    • - *
    • 응답 속도가 빠름
    • - *
    - *
  • - *
  • LEDGER: 자동차 등록원부(갑) 조회 서비스 - *
      - *
    • 상세한 등록 정보 및 법적 권리관계 조회
    • - *
    • 저당권, 압류, 소유권 이전 이력 등 포함
    • - *
    • 민감 정보를 포함하여 권한 검증이 엄격함
    • - *
    - *
  • - *
- */ - public enum ServiceType { - /** - * Basic service type. - */ - BASIC, - /** - * Ledger service type. - */ - LEDGER } - - /** - * HTTP 헤더 구성 - * - *

정부 API 호출에 필요한 모든 HTTP 헤더를 구성하는 private 메서드입니다. - * 정부 시스템은 엄격한 헤더 검증을 수행하므로 모든 필수 헤더가 정확히 포함되어야 합니다.

- * - *

헤더 구성 항목:

- * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
헤더명설명예시값필수여부
Content-Type요청 바디의 미디어 타입 및 문자 인코딩application/json; charset=UTF-8필수
Accept클라이언트가 수용 가능한 응답 형식application/json필수
gpki_ynGPKI 암호화 사용 여부 (Y/N)Y필수
tx_id트랜잭션 고유 ID (요청 추적용)20250104123045_abc123필수
cert_server_id인증서 서버 식별자VMIS_SERVER_01필수
api_key서비스별 API 인증 키abc123def456...필수
cvmis_apikeyCVMIS 시스템 API 키xyz789uvw012...필수
INFO_SYS_ID정보시스템 식별자VMIS_SEOUL필수
- * - *

문자 인코딩:

- *
    - *
  • Content-Type에 UTF-8 인코딩을 명시적으로 지정
  • - *
  • 한글 데이터 처리를 위해 필수
  • - *
  • 정부 시스템이 다양한 클라이언트와 호환되도록 표준 인코딩 사용
  • - *
- * - *

보안 고려사항:

- *
    - *
  • API 키는 설정 파일에서 안전하게 관리
  • - *
  • 로그에 API 키가 노출되지 않도록 주의
  • - *
  • 각 서비스(BASIC, LEDGER)마다 별도의 API 키 사용 가능
  • - *
- * - * @param svc 서비스 설정 객체 (API 키, 경로 등 포함) - * @param txId 이번 요청의 트랜잭션 ID - * @return HttpHeaders 구성된 HTTP 헤더 객체 - */ - private HttpHeaders buildHeaders(VmisProperties.GovProps.Service svc, String txId) { - // 1. 빈 HttpHeaders 객체 생성 - HttpHeaders headers = new HttpHeaders(); - - // 2. Content-Type 설정 - // UTF-8 인코딩을 명시하여 한글 데이터가 올바르게 전송되도록 함 - headers.setContentType(new MediaType("application", "json", StandardCharsets.UTF_8)); - - // 3. Accept 헤더 설정 - // 서버에게 JSON 형식의 응답을 요청함 - headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); - - // 4. GPKI 암호화 사용 여부 - // 정부 서버가 요청 바디 복호화 여부를 결정하는 데 사용 - headers.add("gpki_yn", gpkiService.isEnabled() ? "Y" : "N"); - - // 5. 트랜잭션 ID - // 요청 추적, 로그 연관, 문제 해결 시 사용 - headers.add("tx_id", txId); - - // 6. 인증서 서버 ID - // GPKI 인증서를 발급받은 서버의 식별자 - headers.add("cert_server_id", props.getGpki().getCertServerId()); - - // 7. API 인증 키 - // 서비스별로 다른 API 키 사용 가능 (BASIC과 LEDGER 각각) - headers.add("api_key", svc.getApiKey()); - - // 8. CVMIS API 키 - // CVMIS(Car Vehicle Management Information System) 전용 API 키 - headers.add("cvmis_apikey", svc.getCvmisApikey()); - - // 구성 완료된 헤더 반환 - return headers; - } - - /** - * 자동차 기본사항 조회 API 호출 - * - *

타입 안전성이 보장되는 자동차 기본사항 조회 메서드입니다. - * 내부적으로 {@link #callModel}을 호출하여 실제 통신을 수행합니다.

- * - *

특징:

- *
    - *
  • 제네릭 타입으로 컴파일 타입 타입 체크
  • - *
  • 요청/응답 객체가 Envelope로 감싸져 있음
  • - *
  • Jackson TypeReference를 사용한 제네릭 역직렬화
  • - *
  • API 호출 전후로 DB에 로그성 데이터 저장
  • - *
- * - *

처리 흐름:

- *
    - *
  1. 요청 정보를 DB에 INSERT (로그 저장)
  2. - *
  3. 정부 API 호출
  4. - *
  5. 응답 정보를 DB에 UPDATE
  6. - *
  7. 에러 발생 시 에러 정보도 DB에 UPDATE
  8. - *
- * - *

사용 예시:

- *
-     * BasicRequest request = new BasicRequest();
-     * request.setVehicleNo("12가3456");
-     *
-     * Envelope<BasicRequest> envelope = new Envelope<>();
-     * envelope.setData(request);
-     *
-     * ResponseEntity<Envelope<BasicResponse>> response = govClient.callBasic(envelope);
-     * BasicResponse data = response.getBody().getData();
-     * 
- * - * @param envelope 자동차 기본사항 조회 요청을 담은 Envelope - * @return ResponseEntity<Envelope<BasicResponse>> 조회 결과를 담은 응답 - */ - public ResponseEntity> callBasic(Envelope envelope) { - // 순수한 전송 책임만 수행: DB 로깅은 서비스 레이어에서 처리 - return callModel(ServiceType.BASIC, envelope, new TypeReference>(){}); - } - - /** - * 자동차 등록원부(갑) 조회 API 호출 - * - *

타입 안전성이 보장되는 자동차 등록원부 조회 메서드입니다. - * 내부적으로 {@link #callModel}을 호출하여 실제 통신을 수행합니다.

- * - *

특징:

- *
    - *
  • 제네릭 타입으로 컴파일 타임 타입 체크
  • - *
  • 요청/응답 객체가 Envelope로 감싸져 있음
  • - *
  • Jackson TypeReference를 사용한 제네릭 역직렬화
  • - *
- * - *

사용 예시:

- *
-     * LedgerRequest request = new LedgerRequest();
-     * request.setVehicleNo("12가3456");
-     * request.setOwnerName("홍길동");
-     *
-     * Envelope<LedgerRequest> envelope = new Envelope<>();
-     * envelope.setData(request);
-     *
-     * ResponseEntity<Envelope<LedgerResponse>> response = govClient.callLedger(envelope);
-     * LedgerResponse data = response.getBody().getData();
-     * 
- * - * @param envelope 자동차 등록원부 조회 요청을 담은 Envelope - * @return ResponseEntity<Envelope<LedgerResponse>> 조회 결과를 담은 응답 - */ - public ResponseEntity> callLedger(Envelope envelope) { - // TypeReference를 사용하여 제네릭 타입 정보 전달 - // 익명 클래스를 생성하여 타입 소거(Type Erasure) 문제 해결 - return callModel(ServiceType.LEDGER, envelope, new TypeReference>(){}); - } - - /** - * 정부 API 호출 (타입 안전 모델 기반) - * - *

이 메서드는 정부 API 호출의 핵심 로직을 담고 있는 제네릭 메서드입니다. - * Java 객체를 받아 JSON으로 변환하고, 암호화하여 전송한 후, 응답을 복호화하여 - * 다시 Java 객체로 변환하는 전체 파이프라인을 처리합니다.

- * - *

Template Method 패턴:

- *
    - *
  • 이 메서드는 Template Method 패턴의 템플릿 역할
  • - *
  • 공통 처리 흐름을 정의하고 서비스별 차이는 파라미터로 처리
  • - *
  • 코드 중복을 제거하고 일관성을 보장
  • - *
- * - *

제네릭 타입 파라미터:

- *
    - *
  • <TReq>: 요청 데이터 타입 (BasicRequest 또는 LedgerRequest)
  • - *
  • <TResp>: 응답 데이터 타입 (BasicResponse 또는 LedgerResponse)
  • - *
  • 타입 안전성을 보장하여 런타임 에러 방지
  • - *
- * - *

처리 흐름 (상세):

- *
    - *
  1. 설정 로드: - *
    • 서비스 타입에 따라 BASIC 또는 LEDGER 설정 선택
    - *
  2. - *
  3. URL 및 트랜잭션 ID 구성: - *
    • 완전한 API URL 생성
    • - *
    • 고유 트랜잭션 ID 생성
    - *
  4. - *
  5. 직렬화 (Serialization): - *
    • Java 객체(Envelope<TReq>)를 JSON 문자열로 변환
    • - *
    • ObjectMapper.writeValueAsString() 사용
    - *
  6. - *
  7. 헤더 구성: - *
    • buildHeaders() 메서드 호출
    • - *
    • 모든 필수 헤더 추가
    - *
  8. - *
  9. GPKI 암호화 (선택적): - *
    • GPKI가 활성화된 경우 JSON을 암호화
    • - *
    • gpkiEncrypt() 메서드 호출
    - *
  10. - *
  11. HTTP 요청 전송: - *
    • RestTemplate.exchange()로 POST 요청
    • - *
    • 요청 로그 기록
    - *
  12. - *
  13. GPKI 복호화 (선택적): - *
    • 성공 응답(2xx)이고 GPKI가 활성화된 경우
    • - *
    • gpkiDecrypt() 메서드 호출
    - *
  14. - *
  15. 역직렬화 (Deserialization): - *
    • JSON 문자열을 Java 객체(Envelope<TResp>)로 변환
    • - *
    • TypeReference를 사용하여 제네릭 타입 정보 보존
    - *
  16. - *
  17. 응답 반환: - *
    • ResponseEntity로 감싸서 HTTP 정보 포함
    - *
  18. - *
- * - *

에러 처리 전략 (3단계):

- *
    - *
  1. HttpStatusCodeException (HTTP 에러): - *
      - *
    • 정부 API가 4xx 또는 5xx 상태 코드를 반환한 경우
    • - *
    • 에러 응답 바디를 파싱하여 Envelope 객체로 변환 시도
    • - *
    • 파싱 실패 시 빈 Envelope 객체 반환
    • - *
    • 에러 로그 기록 (WARN 레벨)
    • - *
    - *
  2. - *
  3. JSON 파싱 에러: - *
      - *
    • 응답 JSON이 예상한 형식과 다른 경우
    • - *
    • RuntimeException으로 래핑하여 상위로 전파
    • - *
    - *
  4. - *
  5. 기타 예외: - *
      - *
    • 네트워크 타임아웃, 연결 실패 등
    • - *
    • RuntimeException으로 래핑하여 상위로 전파
    • - *
    - *
  6. - *
- * - *

TypeReference의 필요성:

- *

Java의 제네릭은 런타임에 타입 소거(Type Erasure)가 발생하여 - * {@code objectMapper.readValue(json, Envelope.class)}와 같은 코드는 - * 컴파일되지 않습니다. TypeReference는 익명 클래스를 사용하여 컴파일 타임의 - * 제네릭 타입 정보를 런타임에 전달하는 Jackson의 메커니즘입니다.

- * - *

로깅 정보:

- *
    - *
  • 요청 로그: [GOV-REQ] url, tx_id, gpki, length
  • - *
  • 에러 로그: [GOV-ERR] status, body
  • - *
- * - * @param 요청 데이터의 제네릭 타입 - * @param 응답 데이터의 제네릭 타입 - * @param type 서비스 타입 (BASIC 또는 LEDGER) - * @param envelope 요청 데이터를 담은 Envelope 객체 - * @param respType 응답 타입에 대한 TypeReference (제네릭 타입 정보 보존용) - * @return ResponseEntity<Envelope<TResp>> 응답 데이터를 담은 ResponseEntity - * @throws RuntimeException JSON 직렬화/역직렬화 실패, 네트워크 오류, GPKI 암복호화 실패 등 - */ - private ResponseEntity> callModel(ServiceType type, - Envelope envelope, - TypeReference> respType) { - // 1. 서비스 타입에 따른 설정 로드 - VmisProperties.GovProps gov = props.getGov(); - VmisProperties.GovProps.Service svc = (type == ServiceType.BASIC) - ? gov.getServices().getBasic() - : gov.getServices().getLedger(); - - // 2. URL 및 트랜잭션 ID 생성 - String url = gov.buildServiceUrl(svc.getPath()); - String txId = TxIdUtil.generate(); - - try { - // 3. 직렬화: Java 객체 → JSON 문자열 - // ObjectMapper가 Envelope 객체를 JSON으로 변환 - // 날짜, null 값 등의 처리는 ObjectMapper 설정에 따름 - String jsonBody = objectMapper.writeValueAsString(envelope); - - // 4. HTTP 헤더 구성 - HttpHeaders headers = buildHeaders(svc, txId); - - // 5. GPKI 암호화 처리 - String bodyToSend = jsonBody; - if (gpkiService.isEnabled()) { - // JSON 평문을 암호화된 문자열로 변환 - bodyToSend = gpkiEncrypt(jsonBody); - } - - // 6. HTTP 엔티티 생성 (헤더 + 바디) - HttpEntity request = new HttpEntity<>(bodyToSend, headers); - - // 7. 요청 로그 기록 - log.info("[GOV-REQ] url={}, tx_id={}, gpki={}, length={}", url, txId, gpkiService.isEnabled(), bodyToSend != null ? bodyToSend.length() : 0); - - // 8. 실제 HTTP POST 요청 전송 - ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, request, String.class); - String respBody = response.getBody(); - - // 9. GPKI 복호화 처리 (성공 응답인 경우만) - if (gpkiService.isEnabled() && response.getStatusCode().is2xxSuccessful()) { - // 암호화된 응답을 평문 JSON으로 복호화 - respBody = gpkiDecrypt(respBody); - } - - // 10. 역직렬화: JSON 문자열 → Java 객체 - // TypeReference를 사용하여 제네릭 타입 정보 전달 - Envelope mapped = objectMapper.readValue(respBody, respType); - - // 11. 응답 반환 (상태 코드, 헤더, 바디 모두 포함) - return ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()).body(mapped); - - } catch (HttpStatusCodeException ex) { - // HTTP 에러 처리 (4xx, 5xx) - log.warn("[GOV-ERR] status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString()); - - // 에러 응답 바디 파싱 시도 - // 정부 API는 에러 응답도 Envelope 형식으로 반환할 수 있음 - Envelope empty = new Envelope<>(); - try { - // 에러 응답을 Envelope 객체로 파싱 - Envelope parsed = objectMapper.readValue(ex.getResponseBodyAsString(), respType); - return ResponseEntity.status(ex.getStatusCode()).headers(ex.getResponseHeaders() != null ? ex.getResponseHeaders() : new HttpHeaders()).body(parsed); - } catch (Exception parseEx) { - // 파싱 실패 시 빈 Envelope 반환 - // 호출자는 HTTP 상태 코드로 에러 판단 가능 - return ResponseEntity.status(ex.getStatusCode()).headers(ex.getResponseHeaders() != null ? ex.getResponseHeaders() : new HttpHeaders()).body(empty); - } - } catch (Exception e) { - // 기타 모든 예외 (네트워크 오류, JSON 파싱 오류, GPKI 오류 등) - // RuntimeException으로 래핑하여 상위로 전파 - // Spring의 @ExceptionHandler에서 처리 가능 - throw new RuntimeException("정부 API 호출 중 오류", e); - } - } - - /** - * GPKI 암호화 처리 - * - *

평문 JSON 문자열을 GPKI(행정전자서명) 알고리즘으로 암호화하는 헬퍼 메서드입니다. - * 암호화 실패 시 명확한 에러 메시지와 함께 RuntimeException을 발생시킵니다.

- * - *

암호화 과정:

- *
    - *
  1. 평문 JSON 문자열을 바이트 배열로 변환
  2. - *
  3. 정부 시스템의 공개키를 사용하여 암호화
  4. - *
  5. 암호화된 바이트 배열을 Base64로 인코딩
  6. - *
  7. Base64 문자열 반환
  8. - *
- * - *

에러 처리:

- *
    - *
  • 인증서 오류: GPKI 인증서가 유효하지 않거나 만료된 경우
  • - *
  • 암호화 오류: 암호화 알고리즘 실행 중 오류 발생
  • - *
  • 인코딩 오류: Base64 인코딩 실패
  • - *
  • 모든 예외는 RuntimeException으로 래핑하여 즉시 중단
  • - *
- * - *

보안 고려사항:

- *
    - *
  • 공개키 암호화 방식 사용 (비대칭키)
  • - *
  • 정부 시스템만 개인키로 복호화 가능
  • - *
  • 민감한 개인정보 보호
  • - *
- * - * @param jsonBody 암호화할 평문 JSON 문자열 - * @return String Base64로 인코딩된 암호화 문자열 - * @throws RuntimeException GPKI 암호화 실패 시 - */ - private String gpkiEncrypt(String jsonBody) { - try { - // GpkiService에 암호화 위임 - // 실제 암호화 로직은 GpkiService가 캡슐화 - return gpkiService.encrypt(jsonBody); - } catch (Exception e) { - // 암호화 실패는 치명적 오류 - // 평문 데이터를 전송할 수 없으므로 즉시 중단 - throw new RuntimeException("GPKI 암호화 실패", e); - } - } - - /** - * GPKI 복호화 처리 - * - *

암호화된 응답 문자열을 GPKI(행정전자서명) 알고리즘으로 복호화하는 헬퍼 메서드입니다. - * 복호화 실패 시 명확한 에러 메시지와 함께 RuntimeException을 발생시킵니다.

- * - *

복호화 과정:

- *
    - *
  1. Base64로 인코딩된 암호문을 바이트 배열로 디코딩
  2. - *
  3. 우리 시스템의 개인키를 사용하여 복호화
  4. - *
  5. 복호화된 바이트 배열을 UTF-8 문자열로 변환
  6. - *
  7. 평문 JSON 문자열 반환
  8. - *
- * - *

에러 처리:

- *
    - *
  • 인증서 오류: GPKI 인증서가 유효하지 않거나 만료된 경우
  • - *
  • 복호화 오류: 암호문이 손상되었거나 올바르지 않은 경우
  • - *
  • 디코딩 오류: Base64 디코딩 실패
  • - *
  • 문자 인코딩 오류: UTF-8 변환 실패
  • - *
  • 모든 예외는 RuntimeException으로 래핑하여 즉시 중단
  • - *
- * - *

보안 고려사항:

- *
    - *
  • 개인키 암호화 방식 사용 (비대칭키)
  • - *
  • 개인키는 안전하게 저장 및 관리 필요
  • - *
  • 복호화 실패 시 상세 에러 정보 로깅 주의 (정보 유출 방지)
  • - *
- * - * @param cipher Base64로 인코딩된 암호화 문자열 - * @return String 복호화된 평문 JSON 문자열 - * @throws RuntimeException GPKI 복호화 실패 시 - */ - private String gpkiDecrypt(String cipher) { - try { - // GpkiService에 복호화 위임 - // 실제 복호화 로직은 GpkiService가 캡슐화 - return gpkiService.decrypt(cipher); - } catch (Exception e) { - // 복호화 실패는 치명적 오류 - // 암호문을 해석할 수 없으므로 즉시 중단 - throw new RuntimeException("GPKI 복호화 실패", e); - } - } - -} diff --git a/src/main/java/go/kr/project/api/internal/config/GpkiConfig.java b/src/main/java/go/kr/project/api/internal/config/GpkiConfig.java deleted file mode 100644 index 12210c9..0000000 --- a/src/main/java/go/kr/project/api/internal/config/GpkiConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package go.kr.project.api.internal.config; - -import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.internal.gpki.GpkiService; -import go.kr.project.api.internal.gpki.NoopGpkiService; -import go.kr.project.api.internal.gpki.RealGpkiService; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class GpkiConfig { - - @Bean - public GpkiService gpkiService(VmisProperties properties) { - if (properties.getGpki().isEnabledFlag()) { - return new RealGpkiService(properties); - } - return new NoopGpkiService(); - } -} diff --git a/src/main/java/go/kr/project/api/internal/config/OpenApiConfig.java b/src/main/java/go/kr/project/api/internal/config/OpenApiConfig.java deleted file mode 100644 index c9d6277..0000000 --- a/src/main/java/go/kr/project/api/internal/config/OpenApiConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package go.kr.project.api.internal.config; - -import io.swagger.v3.oas.models.ExternalDocumentation; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.info.License; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class OpenApiConfig { - - @Bean - public OpenAPI vmisOpenAPI() { - return new OpenAPI() - .info(new Info() - .title("VMIS Interface API") - .description("시군구연계 자동차 정보 인터페이스 API (자망연계)") - .version("v0.1.0") - .contact(new Contact().name("VMIS").email("support@example.com")) - .license(new License().name("Apache 2.0").url("https://www.apache.org/licenses/LICENSE-2.0.html"))) - .externalDocs(new ExternalDocumentation() - .description("Reference") - .url("")); - } -} diff --git a/src/main/java/go/kr/project/api/internal/config/PropertiesConfig.java b/src/main/java/go/kr/project/api/internal/config/PropertiesConfig.java deleted file mode 100644 index 76a948e..0000000 --- a/src/main/java/go/kr/project/api/internal/config/PropertiesConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package go.kr.project.api.internal.config; - -import go.kr.project.api.config.properties.VmisProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(VmisProperties.class) -public class PropertiesConfig { -} diff --git a/src/main/java/go/kr/project/api/internal/gpki/GpkiService.java b/src/main/java/go/kr/project/api/internal/gpki/GpkiService.java deleted file mode 100644 index bfb1272..0000000 --- a/src/main/java/go/kr/project/api/internal/gpki/GpkiService.java +++ /dev/null @@ -1,7 +0,0 @@ -package go.kr.project.api.internal.gpki; - -public interface GpkiService { - String encrypt(String plain) throws Exception; - String decrypt(String cipher) throws Exception; - boolean isEnabled(); -} diff --git a/src/main/java/go/kr/project/api/internal/gpki/NoopGpkiService.java b/src/main/java/go/kr/project/api/internal/gpki/NoopGpkiService.java deleted file mode 100644 index 9d4084c..0000000 --- a/src/main/java/go/kr/project/api/internal/gpki/NoopGpkiService.java +++ /dev/null @@ -1,18 +0,0 @@ -package go.kr.project.api.internal.gpki; - -public class NoopGpkiService implements GpkiService { - @Override - public String encrypt(String plain) { - return plain; - } - - @Override - public String decrypt(String cipher) { - return cipher; - } - - @Override - public boolean isEnabled() { - return false; - } -} diff --git a/src/main/java/go/kr/project/api/internal/gpki/RealGpkiService.java b/src/main/java/go/kr/project/api/internal/gpki/RealGpkiService.java deleted file mode 100644 index 294d289..0000000 --- a/src/main/java/go/kr/project/api/internal/gpki/RealGpkiService.java +++ /dev/null @@ -1,41 +0,0 @@ -package go.kr.project.api.internal.gpki; - -import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.internal.util.GpkiCryptoUtil; - -/** - * Real GPKI service backed by native GPKI JNI via legacy NewGpkiUtil wrapper. - * Uses YAML-configured paths and options in {@link VmisProperties.GpkiProps}. - */ -public class RealGpkiService implements GpkiService { - - private final VmisProperties props; - private final GpkiCryptoUtil crypto; - - public RealGpkiService(VmisProperties props) { - this.props = props; - try { - this.crypto = GpkiCryptoUtil.from(props.getGpki()); - } catch (Exception e) { - throw new IllegalStateException("Failed to initialize GPKI (JNI) util. Check YAML paths/passwords and license.", e); - } - } - - @Override - public String encrypt(String plain) throws Exception { - String charset = props.getGpki().getCharset(); - String targetId = props.getGpki().getTargetServerId(); - return crypto.encryptToBase64(plain, targetId, charset); - } - - @Override - public String decrypt(String cipher) throws Exception { - String charset = props.getGpki().getCharset(); - return crypto.decryptFromBase64(cipher, charset); - } - - @Override - public boolean isEnabled() { - return true; - } -} diff --git a/src/main/java/go/kr/project/api/internal/service/VmisCarBassMatterInqireService.java b/src/main/java/go/kr/project/api/internal/service/VmisCarBassMatterInqireService.java deleted file mode 100644 index 45e7672..0000000 --- a/src/main/java/go/kr/project/api/internal/service/VmisCarBassMatterInqireService.java +++ /dev/null @@ -1,28 +0,0 @@ -package go.kr.project.api.internal.service; - -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.response.BasicResponse; -import org.springframework.http.ResponseEntity; - -/** - * 자동차 기본사항 조회 서비스 인터페이스 - * - *

API 호출 정보를 관리하는 서비스입니다.

- *
    - *
  • 요청 데이터 보강
  • - *
  • 최초 요청 로그 저장
  • - *
  • 외부 API 호출
  • - *
  • 응답 로그 업데이트
  • - *
- */ -public interface VmisCarBassMatterInqireService { - - /** - * 자동차 기본사항 조회 - * - * @param envelope 요청 Envelope - * @return 응답 Envelope - */ - ResponseEntity> basic(Envelope envelope); -} diff --git a/src/main/java/go/kr/project/api/internal/service/VmisCarLedgerFrmbkService.java b/src/main/java/go/kr/project/api/internal/service/VmisCarLedgerFrmbkService.java deleted file mode 100644 index 3e30c10..0000000 --- a/src/main/java/go/kr/project/api/internal/service/VmisCarLedgerFrmbkService.java +++ /dev/null @@ -1,21 +0,0 @@ -package go.kr.project.api.internal.service; - -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.LedgerResponse; -import org.springframework.http.ResponseEntity; - -/** - * 자동차 등록 원부(갑) 서비스 인터페이스 - * - 요청 보강, 외부 API 호출, 로그 서비스 위임 - */ -public interface VmisCarLedgerFrmbkService { - - /** - * 자동차 등록원부(갑) 조회 - * - * @param envelope 요청 Envelope - * @return 응답 Envelope - */ - ResponseEntity> ledger(Envelope envelope); -} diff --git a/src/main/java/go/kr/project/api/internal/service/VmisRequestEnricher.java b/src/main/java/go/kr/project/api/internal/service/VmisRequestEnricher.java deleted file mode 100644 index e7fc39d..0000000 --- a/src/main/java/go/kr/project/api/internal/service/VmisRequestEnricher.java +++ /dev/null @@ -1,85 +0,0 @@ -package go.kr.project.api.internal.service; - -import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -/** - * Populates incoming request models with values from YAML configuration. - * Unconditionally overwrites the listed fields per requirement: - * - INFO_SYS_ID, INFO_SYS_IP, SIGUNGU_CODE - * - CNTC_INFO_CODE (service specific) - * - CHARGER_ID, CHARGER_IP, CHARGER_NM - */ -@Slf4j -@Component -public class VmisRequestEnricher { - - private final VmisProperties props; - - public VmisRequestEnricher(VmisProperties props) { - this.props = props; - } - - public void enrichBasic(Envelope envelope) { - if (envelope == null || envelope.getData() == null) return; - VmisProperties.SystemProps sys = props.getSystem(); - String cntc = props.getGov().getServices().getBasic().getCntcInfoCode(); - for (BasicRequest req : envelope.getData()) { - if (req == null) continue; - req.setInfoSysId(sys.getInfoSysId()); - req.setInfoSysIp(sys.getInfoSysIp()); - req.setSigunguCode(sys.getSigunguCode()); - req.setCntcInfoCode(cntc); - req.setChargerId(sys.getChargerId()); - req.setChargerIp(sys.getChargerIp()); - req.setChargerNm(sys.getChargerNm()); - - // 조회구분코드 자동 설정: VHRNO가 있으면 "3" (자동차번호), VIN이 있으면 "2" (차대번호) - if (req.getInqireSeCode() == null) { - if (req.getVhrno() != null && !req.getVhrno().trim().isEmpty()) { - req.setInqireSeCode("3"); // 자동차번호로 조회 - } else if (req.getVin() != null && !req.getVin().trim().isEmpty()) { - req.setInqireSeCode("2"); // 차대번호로 조회 - } - } - } - log.debug("[ENRICH] basic: applied INFO_SYS_ID={}, INFO_SYS_IP={}, SIGUNGU_CODE={}, CNTC_INFO_CODE={}", - sys.getInfoSysId(), sys.getInfoSysIp(), sys.getSigunguCode(), cntc); - } - - public void enrichLedger(Envelope envelope) { - if (envelope == null || envelope.getData() == null) return; - VmisProperties.SystemProps sys = props.getSystem(); - String cntc = props.getGov().getServices().getLedger().getCntcInfoCode(); - for (LedgerRequest req : envelope.getData()) { - if (req == null) continue; - req.setInfoSysId(sys.getInfoSysId()); - req.setInfoSysIp(sys.getInfoSysIp()); - req.setSigunguCode(sys.getSigunguCode()); - req.setCntcInfoCode(cntc); - req.setChargerId(sys.getChargerId()); - req.setChargerIp(sys.getChargerIp()); - req.setChargerNm(sys.getChargerNm()); - - // 고정값 설정 (값이 없는 경우에만 설정) - if (req.getOnesInformationOpen() == null || req.getOnesInformationOpen().isEmpty()) { - req.setOnesInformationOpen("1"); // 개인정보공개 (소유자공개) - } - if (req.getRouteSeCode() == null || req.getRouteSeCode().isEmpty()) { - req.setRouteSeCode("3"); // 경로구분코드 - } - if (req.getDetailExpression() == null || req.getDetailExpression().isEmpty()) { - req.setDetailExpression("1"); // 내역표시 (전체내역) - } - if (req.getInqireSeCode() == null || req.getInqireSeCode().isEmpty()) { - req.setInqireSeCode("1"); // 조회구분코드 (열람) - } - } - log.debug("[ENRICH] ledger: applied INFO_SYS_ID={}, INFO_SYS_IP={}, SIGUNGU_CODE={}, CNTC_INFO_CODE={}", - sys.getInfoSysId(), sys.getInfoSysIp(), sys.getSigunguCode(), cntc); - } -} diff --git a/src/main/java/go/kr/project/api/internal/service/impl/InternalVehicleInfoServiceImpl.java b/src/main/java/go/kr/project/api/internal/service/impl/InternalVehicleInfoServiceImpl.java deleted file mode 100644 index d5491f5..0000000 --- a/src/main/java/go/kr/project/api/internal/service/impl/InternalVehicleInfoServiceImpl.java +++ /dev/null @@ -1,213 +0,0 @@ -package go.kr.project.api.internal.service.impl; - -import go.kr.project.api.config.ApiConstant; -import go.kr.project.api.internal.service.VmisCarBassMatterInqireService; -import go.kr.project.api.internal.service.VmisCarLedgerFrmbkService; -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.VehicleApiResponseVO; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.LedgerResponse; -import go.kr.project.api.service.VehicleInfoService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.List; - -/** - * 내부 VMIS 모듈을 직접 호출하는 차량 정보 조회 서비스 구현체 - * - *

이 구현체는 외부 REST API 호출 없이 내부 VMIS 모듈을 직접 사용하여 - * 정부 시스템과 통신합니다. 네트워크 오버헤드가 없어 성능이 향상됩니다.

- * - *

활성화 조건:

- *
- * # application.yml
- * vmis:
- *   integration:
- *     mode: internal
- * 
- * - *

처리 흐름:

- *
    - *
  1. 차량번호를 받아 BasicRequest, LedgerRequest 생성
  2. - *
  3. VmisCarBassMatterInqireService.basic() 호출 (기본정보)
  4. - *
  5. VmisCarLedgerFrmbkService.ledger() 호출 (등록원부)
  6. - *
  7. BasicResponse, LedgerResponse를 직접 VehicleApiResponseVO에 설정
  8. - *
  9. VehicleApiResponseVO로 결과 반환
  10. - *
- * - * @see VehicleInfoService - * @see VmisCarBassMatterInqireService - * @see VmisCarLedgerFrmbkService - */ -@Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnProperty(name = "vmis.integration.mode", havingValue = "internal") -public class InternalVehicleInfoServiceImpl extends EgovAbstractServiceImpl implements VehicleInfoService { - - private final VmisCarBassMatterInqireService carBassMatterInqireService; - private final VmisCarLedgerFrmbkService carLedgerFrmbkService; - private final go.kr.project.carInspectionPenalty.history.service.VehicleApiHistoryService vehicleApiHistoryService; - - - @Override - public VehicleApiResponseVO getVehicleInfo(BasicRequest basicRequest) { - String vehicleNumber = basicRequest.getVhrno(); - log.info("[Internal Mode] 차량 정보 조회 시작 - 차량번호: {}, 부과기준일: {}, 조회구분: {}", - vehicleNumber, basicRequest.getLevyStdde(), basicRequest.getInqireSeCode()); - - VehicleApiResponseVO response = new VehicleApiResponseVO(); - response.setVhrno(vehicleNumber); - - try { - // 1. 차량 기본정보 조회 - // 중요 로직: BasicRequest 전체를 사용하여 조회 (RequestEnricher가 나머지 채움) - BasicResponse basicInfo = getBasicInfo(basicRequest); - response.setBasicInfo(basicInfo); - - // 2. 자동차 등록원부 조회 - // 중요 로직: 통합 조회 시에는 차량번호와 기본정보를 바탕으로 LedgerRequest 생성 (RequestEnricher가 나머지 채움) - LedgerRequest ledgerRequest = new LedgerRequest(); - ledgerRequest.setVhrno(vehicleNumber); - ledgerRequest.setOnesInformationOpen("1"); //개인정보공개 {1:소유자공개, 2:비공개, 3:비공개(주민등록번호), 4:비공개(사용본거지)} - - // basicInfo에서 민원인 정보 가져오기 - if (basicInfo != null && basicInfo.getRecord() != null && !basicInfo.getRecord().isEmpty()) { - BasicResponse.Record record = basicInfo.getRecord().get(0); - ledgerRequest.setCpttrNm(record.getMberNm()); // 민원인성명 - ledgerRequest.setCpttrIhidnum(record.getMberSeNo()); // 민원인주민번호 - } - - // 고정값 설정 - ledgerRequest.setCpttrLegaldongCode(null); // 민원인법정동코드 - - LedgerResponse ledgerInfo = getLedgerInfo(ledgerRequest); - response.setLedgerInfo(ledgerInfo); - - // 3. 결과 검증 - if (basicInfo != null && ApiConstant.CNTC_RESULT_CODE_SUCCESS.equals(basicInfo.getCntcResultCode())) { - response.setSuccess(true); - response.setMessage("조회 성공"); - log.info("[Internal Mode] 차량번호 {} 조회 성공", vehicleNumber); - - // 4. API 호출 성공 시 히스토리 ID 조회 및 설정 - try { - String carBassMatterInqireId = vehicleApiHistoryService.selectLatestCarBassMatterInqireIdByVhclno(vehicleNumber); - String carLedgerFrmbkId = vehicleApiHistoryService.selectLatestCarLedgerFrmbkIdByVhclno(vehicleNumber); - response.setCarBassMatterInqireId(carBassMatterInqireId); - response.setCarLedgerFrmbkId(carLedgerFrmbkId); - log.debug("[Internal Mode] 히스토리 ID 설정 완료 - 차량번호: {}, 기본정보ID: {}, 원부ID: {}", - vehicleNumber, carBassMatterInqireId, carLedgerFrmbkId); - } catch (Exception e) { - log.warn("[Internal Mode] 히스토리 ID 조회 실패 - 차량번호: {}", vehicleNumber, e); - // ID 조회 실패는 치명적이지 않으므로 계속 진행 - } - } else { - response.setSuccess(false); - response.setMessage(basicInfo != null ? basicInfo.getCntcResultDtls() : "조회 실패"); - log.warn("[Internal Mode] 차량번호 {} 조회 실패 - {}", vehicleNumber, response.getMessage()); - } - - } catch (Exception e) { - response.setSuccess(false); - response.setMessage("내부 API 호출 오류: " + e.getMessage()); - log.error("[Internal Mode] 차량번호 {} 내부 API 호출 중 오류 발생", vehicleNumber, e); - } - - return response; - } - - - /** - * 차량 기본정보 조회 (내부 모듈 직접 호출) - * 중요 로직: 기본정보 조회는 BasicRequest 전체를 받아서 내부 서비스에 전달 - * - * @param request 기본정보 조회 요청 (차량번호, 부과기준일, 조회구분 등 포함) - * @return 차량 기본정보 응답 - */ - @Override - public BasicResponse getBasicInfo(BasicRequest request) { - log.debug("[Internal Mode] 차량 기본정보 조회 - 차량번호: {}", request.getVhrno()); - - // Envelope로 감싸기 (요청 객체는 이미 모든 필수 파라미터를 포함) - Envelope requestEnvelope = new Envelope(); - requestEnvelope.setData(Collections.singletonList(request)); - - try { - // 내부 서비스 호출 - ResponseEntity> responseEntity = - carBassMatterInqireService.basic(requestEnvelope); - - if (responseEntity.getBody() != null && - responseEntity.getBody().getData() != null && - !responseEntity.getBody().getData().isEmpty()) { - - return responseEntity.getBody().getData().get(0); - } - - log.warn("[Internal Mode] 차량 기본정보 조회 응답이 비어있음 - 차량번호: {}", request.getVhrno()); - return null; - - } catch (Exception e) { - log.error("[Internal Mode] 차량 기본정보 조회 실패 - 차량번호: {}", request.getVhrno(), e); - throw new RuntimeException("차량 기본정보 조회 실패: " + e.getMessage(), e); - } - } - - /** - * 자동차 등록원부(갑) 조회 (내부 모듈 직접 호출) - * 중요 로직: 등록원부 조회는 LedgerRequest 전체를 받아서 내부 서비스에 전달 - * - * @param request 등록원부 조회 요청 (차량번호, 소유자정보, 조회구분 등 포함) - * @return 등록원부 정보 응답 - */ - @Override - public LedgerResponse getLedgerInfo(LedgerRequest request) { - log.debug("[Internal Mode] 자동차 등록원부 조회 - 차량번호: {}", request.getVhrno()); - - // Envelope로 감싸기 (요청 객체는 이미 모든 필수 파라미터를 포함) - Envelope requestEnvelope = new Envelope(); - requestEnvelope.setData(Collections.singletonList(request)); - - try { - // 내부 서비스 호출 - ResponseEntity> responseEntity = - carLedgerFrmbkService.ledger(requestEnvelope); - - if (responseEntity.getBody() != null && - responseEntity.getBody().getData() != null && - !responseEntity.getBody().getData().isEmpty()) { - - return responseEntity.getBody().getData().get(0); - } - - log.warn("[Internal Mode] 자동차 등록원부 조회 응답이 비어있음 - 차량번호: {}", request.getVhrno()); - return null; - - } catch (Exception e) { - log.error("[Internal Mode] 자동차 등록원부 조회 실패 - 차량번호: {}", request.getVhrno(), e); - throw new RuntimeException("자동차 등록원부 조회 실패: " + e.getMessage(), e); - } - } - - /** - * 성공한 응답 개수 계산 - */ - private int countSuccessful(List responses) { - int count = 0; - for (VehicleApiResponseVO response : responses) { - if (response.isSuccess()) { - count++; - } - } - return count; - } -} diff --git a/src/main/java/go/kr/project/api/internal/service/impl/VmisCarBassMatterInqireServiceImpl.java b/src/main/java/go/kr/project/api/internal/service/impl/VmisCarBassMatterInqireServiceImpl.java deleted file mode 100644 index 4a21bd6..0000000 --- a/src/main/java/go/kr/project/api/internal/service/impl/VmisCarBassMatterInqireServiceImpl.java +++ /dev/null @@ -1,88 +0,0 @@ -package go.kr.project.api.internal.service.impl; - -import go.kr.project.api.config.ApiConstant; -import go.kr.project.api.internal.client.GovernmentApi; -import go.kr.project.api.internal.service.VmisCarBassMatterInqireService; -import go.kr.project.api.internal.service.VmisRequestEnricher; -import go.kr.project.api.internal.util.ExceptionDetailUtil; -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.VmisCarBassMatterInqireVO; -import go.kr.project.api.service.VmisCarBassMatterInqireLogService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 자동차 기본 사항 조회 서비스 구현체 - * - *

API 호출 정보를 관리하는 서비스 클래스입니다.

- *
    - *
  • 최초 요청: createInitialRequest() - 시퀀스로 ID 생성 후 INSERT
  • - *
  • 결과 업데이트: updateResponse() - 응답 데이터 UPDATE
  • - *
- */ -@Slf4j -@Service -@RequiredArgsConstructor -public class VmisCarBassMatterInqireServiceImpl extends EgovAbstractServiceImpl implements VmisCarBassMatterInqireService { - - private final GovernmentApi governmentApi; - private final VmisRequestEnricher enricher; - private final VmisCarBassMatterInqireLogService logService; - - /** - * 자동차 기본사항 조회: 보강 -> 최초요청로그 -> 외부호출 -> 응답로그. - */ - @Override - @Transactional - public ResponseEntity> basic(Envelope envelope) { - // 1) 요청 보강 - enricher.enrichBasic(envelope); - - String generatedId = null; - try { - // 2) 최초 요청 로그 저장 (첫 번째 데이터 기준) - if (envelope.getData() != null && !envelope.getData().isEmpty()) { - BasicRequest req = envelope.getData().get(0); - VmisCarBassMatterInqireVO logEntity = VmisCarBassMatterInqireVO.fromRequest(req); - generatedId = logService.createInitialRequestNewTx(logEntity); - } - - // 3) 외부 API 호출 - ResponseEntity> response = governmentApi.callBasic(envelope); - - // 4) 응답 로그 업데이트 - // 원본 소스, 정상적인 호출, 리턴(에러 리턴포함) 일 경우에만 에러 로그 남김 - if (generatedId != null && response.getBody() != null) { - VmisCarBassMatterInqireVO update = VmisCarBassMatterInqireVO.fromResponse(generatedId, response.getBody()); - if (update != null) { - logService.updateResponseNewTx(update); - } - } - - return response; - } catch (Exception e) { - // 5) 오류 로그 업데이트 - if (generatedId != null) { - try { - String detail = ExceptionDetailUtil.buildForLog(e); - VmisCarBassMatterInqireVO errorLog = VmisCarBassMatterInqireVO.builder() - .carBassMatterInqireId(generatedId) // 자동차기본사항조회 ID (PK) - .cntcResultCode(ApiConstant.CNTC_RESULT_CODE_ERROR) // 연계결과코드 (에러) - .cntcResultDtls(detail) // 연계결과상세 (에러 메시지) - .build(); - logService.updateResponseNewTx(errorLog); - log.error("[BASIC-ERR-LOG] API 호출 에러 정보 저장 완료(별도TX) - ID: {}, detail: {}", generatedId, detail, e); - } catch (Exception ignore) { - log.error("[BASIC-ERR-LOG] 에러 로그 저장 실패 - ID: {}", generatedId, ignore); - } - } - throw e; - } - } -} diff --git a/src/main/java/go/kr/project/api/internal/service/impl/VmisCarLedgerFrmbkServiceImpl.java b/src/main/java/go/kr/project/api/internal/service/impl/VmisCarLedgerFrmbkServiceImpl.java deleted file mode 100644 index e8a05fb..0000000 --- a/src/main/java/go/kr/project/api/internal/service/impl/VmisCarLedgerFrmbkServiceImpl.java +++ /dev/null @@ -1,90 +0,0 @@ -package go.kr.project.api.internal.service.impl; - -import go.kr.project.api.config.ApiConstant; -import go.kr.project.api.internal.client.GovernmentApi; -import go.kr.project.api.internal.service.VmisCarLedgerFrmbkService; -import go.kr.project.api.internal.service.VmisRequestEnricher; -import go.kr.project.api.internal.util.ExceptionDetailUtil; -import go.kr.project.api.model.Envelope; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.LedgerResponse; -import go.kr.project.api.model.response.VmisCarLedgerFrmbkDtlVO; -import go.kr.project.api.model.response.VmisCarLedgerFrmbkVO; -import go.kr.project.api.service.VmisCarLedgerFrmbkLogService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** - * 자동차 등록 원부(갑) 서비스 구현체 (오케스트레이션) - * - 요청 보강, 외부 API 호출, 로그 서비스 위임 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class VmisCarLedgerFrmbkServiceImpl extends EgovAbstractServiceImpl implements VmisCarLedgerFrmbkService { - - private final GovernmentApi governmentApi; - private final VmisRequestEnricher enricher; - private final VmisCarLedgerFrmbkLogService logService; - - /** - * 자동차 등록원부(갑) 조회: 보강 -> 최초요청로그(별도TX) -> 외부호출 -> 응답로그(마스터/상세, 별도TX) -> 오류 시 에러로그(별도TX). - */ - @Override - @Transactional - public ResponseEntity> ledger(Envelope envelope) { - // 1) 요청 보강 - enricher.enrichLedger(envelope); - - String generatedId = null; - try { - // 2) 최초 요청 로그 저장 (첫 번째 데이터 기준) - if (envelope.getData() != null && !envelope.getData().isEmpty()) { - LedgerRequest req = envelope.getData().get(0); - VmisCarLedgerFrmbkVO init = VmisCarLedgerFrmbkVO.fromRequest(req); - generatedId = logService.createInitialRequestNewTx(init); - } - - // 3) 외부 API 호출 - ResponseEntity> response = governmentApi.callLedger(envelope); - - // 4) 응답 로그 업데이트 (마스터 + 상세) - if (generatedId != null && response.getBody() != null && - response.getBody().getData() != null && !response.getBody().getData().isEmpty()) { - LedgerResponse body = response.getBody().getData().get(0); - VmisCarLedgerFrmbkVO masterUpdate = VmisCarLedgerFrmbkVO.fromResponseMaster(generatedId, body); - logService.updateResponseNewTx(masterUpdate); - - List details = VmisCarLedgerFrmbkDtlVO.listFromResponse(body, generatedId); - if (details != null && !details.isEmpty()) { - logService.saveDetailsNewTx(generatedId, details); - } - } - - return response; - } catch (Exception e) { - // 5) 오류 로그 업데이트 - if (generatedId != null) { - try { - String detail = ExceptionDetailUtil.buildForLog(e); - VmisCarLedgerFrmbkVO errorLog = VmisCarLedgerFrmbkVO.builder() - .carLedgerFrmbkId(generatedId) - .cntcResultCode(ApiConstant.CNTC_RESULT_CODE_ERROR) - .cntcResultDtls(detail) - .build(); - logService.updateResponseNewTx(errorLog); - log.error("[LEDGER-ERR-LOG] API 호출 에러 정보 저장 완료(별도TX) - ID: {}, detail: {}", generatedId, detail, e); - } catch (Exception ignore) { - log.error("[LEDGER-ERR-LOG] 에러 로그 저장 실패 - ID: {}", generatedId, ignore); - } - } - throw e; - } - } -} diff --git a/src/main/java/go/kr/project/api/internal/util/GpkiCryptoUtil.java b/src/main/java/go/kr/project/api/internal/util/GpkiCryptoUtil.java deleted file mode 100644 index 1987776..0000000 --- a/src/main/java/go/kr/project/api/internal/util/GpkiCryptoUtil.java +++ /dev/null @@ -1,98 +0,0 @@ -package go.kr.project.api.internal.util; - -import go.kr.project.api.config.properties.VmisProperties; -import lombok.Getter; -import lombok.Setter; - -/** - * Wrapper utility around legacy {@link NewGpkiUtil} using configuration from YAML. - * - * Notes: - * - Place this class under src/main/java/util as requested. - * - Uses Lombok for getters/setters. - */ -@Getter -@Setter -public class GpkiCryptoUtil { - - private String gpkiLicPath; - private Boolean ldap; // null -> legacy default - private String certFilePath; - private String envCertFilePathName; - private String envPrivateKeyFilePathName; - private String envPrivateKeyPasswd; - private String sigCertFilePathName; - private String sigPrivateKeyFilePathName; - private String sigPrivateKeyPasswd; - private String myServerId; // equals to certServerId (INFO system server cert id) - private String targetServerIdList; // comma joined list (can be single id) - - private transient NewGpkiUtil delegate; - - public static GpkiCryptoUtil from(VmisProperties.GpkiProps props) throws Exception { - GpkiCryptoUtil util = new GpkiCryptoUtil(); - util.setGpkiLicPath(props.getGpkiLicPath()); - util.setLdap(props.getLdap()); - util.setCertFilePath(props.getCertFilePath()); - util.setEnvCertFilePathName(props.getEnvCertFilePathName()); - util.setEnvPrivateKeyFilePathName(props.getEnvPrivateKeyFilePathName()); - util.setEnvPrivateKeyPasswd(props.getEnvPrivateKeyPasswd()); - util.setSigCertFilePathName(props.getSigCertFilePathName()); - util.setSigPrivateKeyFilePathName(props.getSigPrivateKeyFilePathName()); - util.setSigPrivateKeyPasswd(props.getSigPrivateKeyPasswd()); - util.setMyServerId(props.getCertServerId()); - // Accept single targetServerId but allow list if provided by YAML in future - util.setTargetServerIdList(props.getTargetServerId()); - util.initialize(); - return util; - } - - public void initialize() throws Exception { - NewGpkiUtil g = new NewGpkiUtil(); - if (gpkiLicPath != null) g.setGpkiLicPath(gpkiLicPath); - if (ldap != null) g.setIsLDAP(ldap); - if (certFilePath != null) g.setCertFilePath(certFilePath); - if (envCertFilePathName != null) g.setEnvCertFilePathName(envCertFilePathName); - if (envPrivateKeyFilePathName != null) g.setEnvPrivateKeyFilePathName(envPrivateKeyFilePathName); - if (envPrivateKeyPasswd != null) g.setEnvPrivateKeyPasswd(envPrivateKeyPasswd); - if (sigCertFilePathName != null) g.setSigCertFilePathName(sigCertFilePathName); - if (sigPrivateKeyFilePathName != null) g.setSigPrivateKeyFilePathName(sigPrivateKeyFilePathName); - if (sigPrivateKeyPasswd != null) g.setSigPrivateKeyPasswd(sigPrivateKeyPasswd); - if (myServerId != null) g.setMyServerId(myServerId); - if (targetServerIdList != null) g.setTargetServerIdList(targetServerIdList); - g.init(); - this.delegate = g; - } - - public String encryptToBase64(String plain, String targetServerId, String charset) throws Exception { - ensureInit(); - byte[] enc = delegate.encrypt(plain.getBytes(charset), targetServerId, true); - return delegate.encode(enc); - } - - public String decryptFromBase64(String base64, String charset) throws Exception { - ensureInit(); - byte[] bin = delegate.decode(base64); - byte[] dec = delegate.decrypt(bin); - return new String(dec, charset); - } - - public String signToBase64(String plain, String charset) throws Exception { - ensureInit(); - byte[] sig = delegate.sign(plain.getBytes(charset)); - return delegate.encode(sig); - } - - public String verifyAndExtractBase64(String signedBase64, String charset) throws Exception { - ensureInit(); - byte[] signed = delegate.decode(signedBase64); - byte[] data = delegate.validate(signed); - return new String(data, charset); - } - - private void ensureInit() { - if (delegate == null) { - throw new IllegalStateException("GpkiCryptoUtil is not initialized. Call initialize() or from(props)."); - } - } -} diff --git a/src/main/java/go/kr/project/api/internal/util/NewGpkiUtil.java b/src/main/java/go/kr/project/api/internal/util/NewGpkiUtil.java deleted file mode 100644 index 08dda0e..0000000 --- a/src/main/java/go/kr/project/api/internal/util/NewGpkiUtil.java +++ /dev/null @@ -1,382 +0,0 @@ -package go.kr.project.api.internal.util; - -import com.gpki.gpkiapi.GpkiApi; -import com.gpki.gpkiapi.cert.X509Certificate; -import com.gpki.gpkiapi.crypto.PrivateKey; -import com.gpki.gpkiapi.storage.Disk; -import com.gpki.gpkiapi_jni; -import lombok.extern.slf4j.Slf4j; - -import java.io.File; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -public class NewGpkiUtil { - byte[] myEnvCert, myEnvKey, mySigCert, mySigKey; - private Map targetServerCertMap = new HashMap(); - - // properties - private String myServerId; - private String targetServerIdList; - private String envCertFilePathName; - private String envPrivateKeyFilePathName; - private String envPrivateKeyPasswd; - private String sigCertFilePathName; - private String sigPrivateKeyFilePathName; - private String sigPrivateKeyPasswd; - private String certFilePath; - private String gpkiLicPath = "."; - private boolean isLDAP; - private boolean testGPKI = false; - - - public void init() throws Exception { - GpkiApi.init(gpkiLicPath); - gpkiapi_jni gpki = this.getGPKI(); - if(log.isDebugEnabled()){ - if(gpki.API_GetInfo()==0) - log.debug(gpki.sReturnString); - else - log.error(gpki.sDetailErrorString); - } - if(targetServerIdList!=null){ - String certIdList[] = targetServerIdList.split(","); - for(int i = 0 ; i < certIdList.length ; i++){ - String certId = certIdList[i].trim(); - if(!certId.equals("")){ - load(gpki, certId); - } - } - } - - log.info("Loading gpki certificate : myServerId=" - + this.getMyServerId()); - - X509Certificate _myEnvCert = Disk.readCert(this - .getEnvCertFilePathName()); - myEnvCert = _myEnvCert.getCert(); - - PrivateKey _myEnvKey = Disk.readPriKey(this - .getEnvPrivateKeyFilePathName(), this.getEnvPrivateKeyPasswd()); - myEnvKey = _myEnvKey.getKey(); - - X509Certificate _mySigCert = Disk.readCert(this - .getSigCertFilePathName()); - mySigCert = _mySigCert.getCert(); - - PrivateKey _mySigKey = Disk.readPriKey(this - .getSigPrivateKeyFilePathName(), this.getSigPrivateKeyPasswd()); - mySigKey = _mySigKey.getKey(); - - //test my cert GPKI - if(testGPKI){ - load(gpki, this.getMyServerId()); - testGpki(gpki); - } - this.finish(gpki); - log.info("GpkiUtil initialized"); - } - - private void load(gpkiapi_jni gpki, String certId) throws Exception { - - log.debug("Loading gpki certificate : targetServerId="+ certId); - - X509Certificate cert = targetServerCertMap.get(certId); - if (cert != null) { - return; - } - - if (isLDAP) { -// String ldapUrl = "ldap://10.1.7.140:389/cn="; -// String ldapUrl = "ldap://ldap.gcc.go.kr:389/cn="; - String ldapUrl = "ldap://10.1.7.118:389/cn="; // 행정망인 경우 -// String ldapUrl = "ldap://152.99.57.127:389/cn="; // 인터넷망인 경우 - String ldapUri; - if (certId.charAt(3) > '9') { - ldapUri = ",ou=Group of Server,o=Public of Korea,c=KR"; - } else { - ldapUri = ",ou=Group of Server,o=Government of Korea,c=KR"; - } - - int ret = gpki.LDAP_GetAnyDataByURL("userCertificate;binary", ldapUrl + certId + ldapUri); - this.checkResult(ret, gpki); - cert = new X509Certificate(gpki.baReturnArray); - } else { - if(certFilePath != null){ - cert = Disk.readCert(certFilePath + File.separator + certId + ".cer"); - }else{ - log.debug("not certFilePath"); - } - } - - targetServerCertMap.put(certId, cert); - } - - private gpkiapi_jni getGPKI(){ - gpkiapi_jni gpki = new gpkiapi_jni(); - if(gpki.API_Init(gpkiLicPath) != 0){ - log.error(gpki.sDetailErrorString); - } - return gpki; - } - private void finish(gpkiapi_jni gpki){ - if(gpki.API_Finish() != 0){ - log.error(gpki.sDetailErrorString); - } - } - - public byte[] encrypt(byte[] plain, String certId , boolean load) throws Exception { - X509Certificate targetEnvCert = targetServerCertMap.get(certId); - if (targetEnvCert == null) { - throw new Exception("Certificate not found : targetServerId=" + certId); - } - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.CMS_MakeEnvelopedData(targetEnvCert.getCert(), plain, - gpkiapi_jni.SYM_ALG_NEAT_CBC); - checkResult(result, "Fail to encrypt message", gpki); - - return gpki.baReturnArray; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - } - - public byte[] encrypt(byte[] plain, String certId) throws Exception { - return encrypt(plain,certId , false); - } - - public byte[] decrypt(byte[] encrypted) throws Exception { - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.CMS_ProcessEnvelopedData(myEnvCert, myEnvKey, - encrypted); - checkResult(result, "Fail to decrpyt message", gpki); - - return gpki.baReturnArray; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - } - - public byte[] sign(byte[] plain) throws Exception { - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.CMS_MakeSignedData(mySigCert, mySigKey, plain, null); - checkResult(result, "Fail to sign message", gpki); - - return gpki.baReturnArray; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - } - - public byte[] validate(byte[] signed) throws Exception { - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.CMS_ProcessSignedData(signed); - checkResult(result, "Fail to validate signed message", gpki); - return gpki.baData; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - } - - public String encode(byte[] plain) throws Exception { - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.BASE64_Encode(plain); - checkResult(result, "Fail to encode message", gpki); - - return gpki.sReturnString; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - - } - - public byte[] decode(String base64) throws Exception { - - gpkiapi_jni gpki = this.getGPKI(); - try{ - int result = gpki.BASE64_Decode(base64); - checkResult(result, "Fail to decode base64 message", gpki); - - return gpki.baReturnArray; - }catch(Exception ex){ - throw ex; - }finally{ - finish(gpki); - } - } - - private void checkResult(int result, gpkiapi_jni gpki)throws Exception{ - this.checkResult(result, null, gpki); - } - - private void checkResult(int result ,String message, gpkiapi_jni gpki)throws Exception{ - if( 0 != result){ - if(null != gpki){ - throw new Exception(message + " : gpkiErrorMessage=" + gpki.sDetailErrorString); - }else{ - throw new Exception(message + " : gpkiErrorCode=" + result); - } - } - } - - public void testGpki(gpkiapi_jni gpki) throws Exception{ - //gpki test eng - log.info("======================================================="); - log.info("================ TEST GPKI START ======================"); - log.info("======================================================="); - String original_Eng = "abc"; - log.info("=== TEST ENG STRING: "+ original_Eng); - try { - byte[] encrypted = encrypt(original_Eng.getBytes(), myServerId); - log.info("=== TEST ENG ENCRYPT STRING: "+ encode(encrypted)); - String decrypted = new String(decrypt(encrypted)); - log.info("=== TEST ENG DECRYPT STRING: "+decrypted); - - if (!original_Eng.equals(decrypted)) { - throw new Exception("GpkiUtil not initialized properly(english)"); - } - log.info("=== TEST ENG: OK"); - } catch (Exception e) { - log.warn("Gpki Test error(english)", e); - throw e; - } - //gpki test kor - String original = "한글테스트"; - log.info("=== TEST KOR STRING: "+ original); - try { - byte[] encrypted = encrypt(original.getBytes(), myServerId); - log.info("=== TEST KOR ENCRYPT STRING: "+ encode(encrypted)); - String decrypted = new String(decrypt(encrypted)); - log.info("=== TEST KOR DECRYPT STRING: "+decrypted); - if (!original.equals(decrypted)) { - throw new Exception("GpkiUtil not initialized properly(korean)"); - } - log.info("=== TEST KOR: OK"); - } catch (Exception e) { - log.warn("Gpki Test error(korean)", e); - throw e; - }finally{ - log.info("======================================================="); - log.info("================ TEST GPKI END ========================"); - log.info("======================================================="); - } - } - - public String getMyServerId() { - return myServerId; - } - - public void setMyServerId(String myServerId) { - this.myServerId = myServerId.trim(); - } - - public String getEnvCertFilePathName() { - return envCertFilePathName; - } - - public void setEnvCertFilePathName(String envCertFilePathName) { - this.envCertFilePathName = envCertFilePathName.trim(); - } - - public String getEnvPrivateKeyFilePathName() { - return envPrivateKeyFilePathName; - } - - public void setEnvPrivateKeyFilePathName(String envPrivateKeyFilePathName) { - this.envPrivateKeyFilePathName = envPrivateKeyFilePathName.trim(); - } - - public String getEnvPrivateKeyPasswd() { - return envPrivateKeyPasswd; - } - - public void setEnvPrivateKeyPasswd(String envPrivateKeyPasswd) { - this.envPrivateKeyPasswd = envPrivateKeyPasswd.trim(); - } - - public String getSigPrivateKeyPasswd() { - return sigPrivateKeyPasswd; - } - - public void setSigPrivateKeyPasswd(String sigPrivateKeyPasswd) { - this.sigPrivateKeyPasswd = sigPrivateKeyPasswd.trim(); - } - - public String getSigCertFilePathName() { - return sigCertFilePathName; - } - - public void setSigCertFilePathName(String sigCertFilePathName) { - this.sigCertFilePathName = sigCertFilePathName.trim(); - } - - public String getSigPrivateKeyFilePathName() { - return sigPrivateKeyFilePathName; - } - - public void setSigPrivateKeyFilePathName(String sigPrivateKeyFilePathName) { - this.sigPrivateKeyFilePathName = sigPrivateKeyFilePathName.trim(); - } - - public boolean getIsLDAP() { - return isLDAP; - } - - public void setIsLDAP(boolean isLDAP) { - this.isLDAP = isLDAP; - } - - public String getCertFilePath() { - return certFilePath; - } - - public void setCertFilePath(String certFilePath) { - this.certFilePath = certFilePath.trim(); - } - - public String getTargetServerIdList() { - return targetServerIdList; - } - - public void setTargetServerIdList(String targetServerIdList) { - this.targetServerIdList = targetServerIdList; - } - - public String getGpkiLicPath() { - return gpkiLicPath; - } - - public void setGpkiLicPath(String gpkiLicPath) { - this.gpkiLicPath = gpkiLicPath; - } - - public boolean getTestGPKI() { - return testGPKI; - } - - public void setTestGPKI(boolean testGPKI) { - this.testGPKI = testGPKI; - } - -} diff --git a/src/main/java/go/kr/project/api/internal/util/TxIdUtil.java b/src/main/java/go/kr/project/api/internal/util/TxIdUtil.java deleted file mode 100644 index ceb20fd..0000000 --- a/src/main/java/go/kr/project/api/internal/util/TxIdUtil.java +++ /dev/null @@ -1,18 +0,0 @@ -package go.kr.project.api.internal.util; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.Random; - -public final class TxIdUtil { - private static final Random RANDOM = new Random(); - - private TxIdUtil() {} - - public static String generate() { - String time = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.KOREA).format(new Date()); - int random = 100000 + RANDOM.nextInt(900000); - return time + "_" + random; - } -} diff --git a/src/main/java/go/kr/project/api/internal/mapper/VmisCarBassMatterInqireMapper.java b/src/main/java/go/kr/project/api/mapper/VmisCarBassMatterInqireMapper.java similarity index 97% rename from src/main/java/go/kr/project/api/internal/mapper/VmisCarBassMatterInqireMapper.java rename to src/main/java/go/kr/project/api/mapper/VmisCarBassMatterInqireMapper.java index 7dc49cc..74b09e5 100644 --- a/src/main/java/go/kr/project/api/internal/mapper/VmisCarBassMatterInqireMapper.java +++ b/src/main/java/go/kr/project/api/mapper/VmisCarBassMatterInqireMapper.java @@ -1,4 +1,4 @@ -package go.kr.project.api.internal.mapper; +package go.kr.project.api.mapper; import go.kr.project.api.model.response.VmisCarBassMatterInqireVO; import org.apache.ibatis.annotations.Mapper; diff --git a/src/main/java/go/kr/project/api/internal/mapper/VmisCarLedgerFrmbkMapper.java b/src/main/java/go/kr/project/api/mapper/VmisCarLedgerFrmbkMapper.java similarity index 95% rename from src/main/java/go/kr/project/api/internal/mapper/VmisCarLedgerFrmbkMapper.java rename to src/main/java/go/kr/project/api/mapper/VmisCarLedgerFrmbkMapper.java index 34e5ef0..cb4a7b7 100644 --- a/src/main/java/go/kr/project/api/internal/mapper/VmisCarLedgerFrmbkMapper.java +++ b/src/main/java/go/kr/project/api/mapper/VmisCarLedgerFrmbkMapper.java @@ -1,4 +1,4 @@ -package go.kr.project.api.internal.mapper; +package go.kr.project.api.mapper; import go.kr.project.api.model.response.VmisCarLedgerFrmbkDtlVO; import go.kr.project.api.model.response.VmisCarLedgerFrmbkVO; diff --git a/src/main/java/go/kr/project/api/external/service/ExternalVehicleApiService.java b/src/main/java/go/kr/project/api/service/ExternalVehicleApiService.java similarity index 69% rename from src/main/java/go/kr/project/api/external/service/ExternalVehicleApiService.java rename to src/main/java/go/kr/project/api/service/ExternalVehicleApiService.java index 8ffa5b8..b35e802 100644 --- a/src/main/java/go/kr/project/api/external/service/ExternalVehicleApiService.java +++ b/src/main/java/go/kr/project/api/service/ExternalVehicleApiService.java @@ -1,6 +1,5 @@ -package go.kr.project.api.external.service; +package go.kr.project.api.service; -import go.kr.project.api.model.VehicleApiResponseVO; import go.kr.project.api.model.request.BasicRequest; import go.kr.project.api.model.request.LedgerRequest; import go.kr.project.api.model.response.BasicResponse; @@ -12,14 +11,6 @@ import go.kr.project.api.model.response.LedgerResponse; */ public interface ExternalVehicleApiService { - /** - * 단일 차량에 대한 정보 조회 (상세 파라미터 포함) - * - * @param basicRequest 기본정보 조회 요청 (차량번호, 부과기준일, 조회구분 등 포함) - * @return 차량 정보 응답 - */ - VehicleApiResponseVO getVehicleInfo(BasicRequest basicRequest); - /** * 차량 기본정보만 조회 (외부 REST 호출) * 중요: 기본정보 조회는 차량번호 외에 부과기준일, 조회구분 등 필수 파라미터 필요 diff --git a/src/main/java/go/kr/project/api/service/VehicleInfoService.java b/src/main/java/go/kr/project/api/service/VehicleInfoService.java deleted file mode 100644 index d20bfc8..0000000 --- a/src/main/java/go/kr/project/api/service/VehicleInfoService.java +++ /dev/null @@ -1,76 +0,0 @@ -package go.kr.project.api.service; - -import go.kr.project.api.model.VehicleApiResponseVO; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.model.request.LedgerRequest; -import go.kr.project.api.model.response.BasicResponse; -import go.kr.project.api.model.response.LedgerResponse; - -/** - * 차량 정보 조회 서비스 인터페이스 - * - *

이 인터페이스는 차량 정보를 조회하는 두 가지 구현체를 추상화합니다:

- *
    - *
  • InternalVehicleInfoServiceImpl: 내부 VMIS 모듈을 직접 호출 (vmis.integration.mode=internal)
  • - *
  • ExternalVehicleInfoServiceImpl: 외부 REST API를 호출 (vmis.integration.mode=external)
  • - *
- * - *

설정 방법:

- *
- * # application.yml
- * vmis:
- *   integration:
- *     mode: internal  # 또는 external
- * 
- * - *

사용 예시:

- *
- * {@code
- * @Autowired
- * private VehicleInfoService vehicleInfoService;
- *
- * // 단일 차량 조회
- * VehicleApiResponseVO response = vehicleInfoService.getVehicleInfo("12가3456");
- *
- * // 여러 차량 일괄 조회
- * List responses = vehicleInfoService.getVehiclesInfo(
- *     Arrays.asList("12가3456", "34나5678")
- * );
- *
- * // 단독 조회 (기본/등록원부)
- * BasicResponse basic = vehicleInfoService.getBasicInfo("12가3456");
- * LedgerResponse ledger = vehicleInfoService.getLedgerInfo("12가3456");
- * }
- * 
- */ -public interface VehicleInfoService { - - /** - * 단일 차량에 대한 정보 조회 (상세 파라미터 포함) - * - *

차량 기본정보와 등록원부 정보를 함께 조회합니다.

- *

차량번호 외에 부과기준일, 조회구분, 차대번호 등 추가 파라미터를 포함하여 조회할 수 있습니다.

- * - * @param basicRequest 기본정보 조회 요청 (차량번호, 부과기준일, 조회구분 등 포함) - * @return 차량 정보 응답 (기본정보 + 등록원부 정보) - */ - VehicleApiResponseVO getVehicleInfo(BasicRequest basicRequest); - - /** - * 차량 기본정보만 조회 (단독) - * 중요: 차량번호 외에 부과기준일, 조회구분, 차대번호 등 필수 파라미터를 모두 포함한 BasicRequest 필요 - * - * @param request 기본정보 조회 요청 (차량번호, 부과기준일, 조회구분 등 포함) - * @return 기본정보 응답 - */ - BasicResponse getBasicInfo(BasicRequest request); - - /** - * 자동차 등록원부(갑)만 조회 (단독) - * 중요: 차량번호 외에 소유자정보, 조회구분 등 필수 파라미터를 모두 포함한 LedgerRequest 필요 - * - * @param request 등록원부 조회 요청 (차량번호, 소유자정보, 조회구분 등 포함) - * @return 등록원부 응답 - */ - LedgerResponse getLedgerInfo(LedgerRequest request); -} diff --git a/src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleApiServiceImpl.java b/src/main/java/go/kr/project/api/service/impl/ExternalVehicleApiServiceImpl.java similarity index 75% rename from src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleApiServiceImpl.java rename to src/main/java/go/kr/project/api/service/impl/ExternalVehicleApiServiceImpl.java index c606fdc..262d3db 100644 --- a/src/main/java/go/kr/project/api/external/service/impl/ExternalVehicleApiServiceImpl.java +++ b/src/main/java/go/kr/project/api/service/impl/ExternalVehicleApiServiceImpl.java @@ -1,10 +1,10 @@ -package go.kr.project.api.external.service.impl; +package go.kr.project.api.service.impl; import egovframework.exception.MessageException; import go.kr.project.api.config.ApiConstant; import go.kr.project.api.config.properties.VmisProperties; -import go.kr.project.api.external.service.ExternalVehicleApiService; -import go.kr.project.api.internal.util.ExceptionDetailUtil; +import go.kr.project.api.service.ExternalVehicleApiService; +import go.kr.project.api.util.ExceptionDetailUtil; import go.kr.project.api.model.Envelope; import go.kr.project.api.model.VehicleApiResponseVO; import go.kr.project.api.model.request.BasicRequest; @@ -36,60 +36,6 @@ public class ExternalVehicleApiServiceImpl extends EgovAbstractServiceImpl imple private final VmisCarBassMatterInqireLogService bassMatterLogService; // 기본사항 조회 로그 서비스 private final VmisCarLedgerFrmbkLogService ledgerLogService; // 등록원부 로그 서비스 - @Override - public VehicleApiResponseVO getVehicleInfo(BasicRequest basicRequest) { - String vehicleNumber = basicRequest.getVhrno(); - log.info("차량 정보 조회 시작 - 차량번호: {}, 부과기준일: {}, 조회구분: {}", - vehicleNumber, basicRequest.getLevyStdde(), basicRequest.getInqireSeCode()); - - VehicleApiResponseVO response = new VehicleApiResponseVO(); - response.setVhrno(vehicleNumber); - - try { - // 1. 차량 기본정보 조회 - // 중요 로직: BasicRequest 전체를 사용하여 조회 - BasicResponse basicInfo = getBasicInfo(basicRequest); - response.setBasicInfo(basicInfo); - - // 2. 자동차 등록원부 조회 - // 중요 로직: 통합 조회 시에는 차량번호와 기본정보를 바탕으로 LedgerRequest 생성 - LedgerRequest ledgerRequest = new LedgerRequest(); - ledgerRequest.setVhrno(vehicleNumber); - - // basicInfo에서 민원인 정보 가져오기 - if (basicInfo != null && basicInfo.getRecord() != null && !basicInfo.getRecord().isEmpty()) { - BasicResponse.Record record = basicInfo.getRecord().get(0); - ledgerRequest.setCpttrNm(record.getMberNm()); // 민원인성명 - ledgerRequest.setCpttrIhidnum(record.getMberSeNo()); // 민원인주민번호 - } - // 고정값 설정 - ledgerRequest.setCpttrLegaldongCode(null); // 민원인법정동코드 - ledgerRequest.setRouteSeCode("3"); // 경로구분코드 - ledgerRequest.setDetailExpression("1"); // 내역표시 (전체내역) - - LedgerResponse ledgerInfo = getLedgerInfo(ledgerRequest); - response.setLedgerInfo(ledgerInfo); - - // 3. 결과 검증 - if (basicInfo != null && ApiConstant.CNTC_RESULT_CODE_SUCCESS.equals(basicInfo.getCntcResultCode())) { - response.setSuccess(true); - response.setMessage("조회 성공"); - log.info("차량번호 {} 조회 성공", vehicleNumber); - } else { - response.setSuccess(false); - response.setMessage(basicInfo != null ? basicInfo.getCntcResultDtls() : "조회 실패"); - log.warn("차량번호 {} 조회 실패 - {}", vehicleNumber, response.getMessage()); - } - - } catch (Exception e) { - response.setSuccess(false); - response.setMessage("API 호출 오류: " + e.getMessage()); - log.error("차량번호 {} API 호출 중 오류 발생", vehicleNumber, e); - } - - return response; - } - /** * 차량 기본정보 조회 API 호출 * 중요 로직: 기본정보 조회는 BasicRequest 전체를 받아서 외부 API에 전달 diff --git a/src/main/java/go/kr/project/api/service/impl/VmisCarBassMatterInqireLogServiceImpl.java b/src/main/java/go/kr/project/api/service/impl/VmisCarBassMatterInqireLogServiceImpl.java index 20bdd1e..b6c8250 100644 --- a/src/main/java/go/kr/project/api/service/impl/VmisCarBassMatterInqireLogServiceImpl.java +++ b/src/main/java/go/kr/project/api/service/impl/VmisCarBassMatterInqireLogServiceImpl.java @@ -1,7 +1,7 @@ package go.kr.project.api.service.impl; import egovframework.util.SessionUtil; -import go.kr.project.api.internal.mapper.VmisCarBassMatterInqireMapper; +import go.kr.project.api.mapper.VmisCarBassMatterInqireMapper; import go.kr.project.api.model.response.VmisCarBassMatterInqireVO; import go.kr.project.api.service.VmisCarBassMatterInqireLogService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/go/kr/project/api/service/impl/VmisCarLedgerFrmbkLogServiceImpl.java b/src/main/java/go/kr/project/api/service/impl/VmisCarLedgerFrmbkLogServiceImpl.java index 4cd6db0..c331fa1 100644 --- a/src/main/java/go/kr/project/api/service/impl/VmisCarLedgerFrmbkLogServiceImpl.java +++ b/src/main/java/go/kr/project/api/service/impl/VmisCarLedgerFrmbkLogServiceImpl.java @@ -1,7 +1,7 @@ package go.kr.project.api.service.impl; import egovframework.util.SessionUtil; -import go.kr.project.api.internal.mapper.VmisCarLedgerFrmbkMapper; +import go.kr.project.api.mapper.VmisCarLedgerFrmbkMapper; import go.kr.project.api.model.response.VmisCarLedgerFrmbkDtlVO; import go.kr.project.api.model.response.VmisCarLedgerFrmbkVO; import go.kr.project.api.service.VmisCarLedgerFrmbkLogService; diff --git a/src/main/java/go/kr/project/api/internal/util/ExceptionDetailUtil.java b/src/main/java/go/kr/project/api/util/ExceptionDetailUtil.java similarity index 95% rename from src/main/java/go/kr/project/api/internal/util/ExceptionDetailUtil.java rename to src/main/java/go/kr/project/api/util/ExceptionDetailUtil.java index ccc1e17..43c15d3 100644 --- a/src/main/java/go/kr/project/api/internal/util/ExceptionDetailUtil.java +++ b/src/main/java/go/kr/project/api/util/ExceptionDetailUtil.java @@ -1,4 +1,4 @@ -package go.kr.project.api.internal.util; +package go.kr.project.api.util; /** * Common helper to extract root-cause message and truncate to DB column limit (default 4000 chars). diff --git a/src/main/java/go/kr/project/carInspectionPenalty/callApi/controller/VehicleInquiryController.java b/src/main/java/go/kr/project/carInspectionPenalty/callApi/controller/VehicleInquiryController.java index f2b3db3..4e218ac 100644 --- a/src/main/java/go/kr/project/carInspectionPenalty/callApi/controller/VehicleInquiryController.java +++ b/src/main/java/go/kr/project/carInspectionPenalty/callApi/controller/VehicleInquiryController.java @@ -2,12 +2,11 @@ package go.kr.project.carInspectionPenalty.callApi.controller; import egovframework.constant.TilesConstants; import egovframework.util.ApiResponseUtil; -import go.kr.project.api.model.VehicleApiResponseVO; import go.kr.project.api.model.request.BasicRequest; import go.kr.project.api.model.request.LedgerRequest; import go.kr.project.api.model.response.BasicResponse; import go.kr.project.api.model.response.LedgerResponse; -import go.kr.project.api.service.VehicleInfoService; +import go.kr.project.api.service.ExternalVehicleApiService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -29,7 +28,7 @@ import org.springframework.web.bind.annotation.*; @Tag(name = "차량 정보 조회", description = "차량 정보 조회 API") public class VehicleInquiryController { - private final VehicleInfoService vehicleInfoService; + private final ExternalVehicleApiService service; /** * 차량 정보 조회 화면 @@ -40,41 +39,6 @@ public class VehicleInquiryController { return "carInspectionPenalty/callApi/inquiry" + TilesConstants.BASE; } - /** - * 자동차 통합 조회 (기본정보 + 등록원부) - * - * @param request 기본정보 조회 요청 - * @return 차량 통합 정보 조회 결과 - */ - @PostMapping("/getIntegratedInfo.do") - @ResponseBody - @Operation(summary = "자동차 통합 조회", description = "차량 기본정보와 등록원부 정보를 함께 조회합니다.") - public ResponseEntity getIntegratedInfo(@RequestBody BasicRequest request) { - log.info("========== 자동차 통합 조회 시작 =========="); - log.info("요청 차량번호: {}", request.getVhrno()); - log.info("부과기준일: {}", request.getLevyStdde()); - log.info("조회구분코드: {}", request.getInqireSeCode()); - log.info("차대번호: {}", request.getVin()); - - // 입력값 검증 - if (!StringUtils.hasText(request.getVhrno())) { - log.warn("차량번호가 입력되지 않았습니다."); - return ApiResponseUtil.error("차량번호를 입력해주세요."); - } - - // 차량 정보 조회 - VehicleApiResponseVO response = vehicleInfoService.getVehicleInfo(request); - if(!response.isSuccess()) { - log.warn("자동차 통합 조회 실패 - 차량번호: {}, 메시지: {}", request.getVhrno(), response.getMessage()); - log.warn("========== 자동차 통합 조회 실패 =========="); - return ApiResponseUtil.error(response.getMessage()); - } - - log.info("자동차 통합 조회 성공 - 차량번호: {}", request.getVhrno()); - log.info("========== 자동차 통합 조회 완료 =========="); - return ApiResponseUtil.success(response, "자동차 통합 조회가 완료되었습니다."); - } - /** * 자동차 기본사항 조회 (단독) * @@ -98,7 +62,7 @@ public class VehicleInquiryController { } // 차량 기본정보 조회 - BasicResponse response = vehicleInfoService.getBasicInfo(request); + BasicResponse response = service.getBasicInfo(request); log.info("자동차 기본사항 조회 성공 - 차량번호: {}", request.getVhrno()); log.info("========== 자동차 기본사항 조회 완료 =========="); @@ -128,7 +92,7 @@ public class VehicleInquiryController { } // 차량 등록원부 조회 - LedgerResponse response = vehicleInfoService.getLedgerInfo(request); + LedgerResponse response = service.getLedgerInfo(request); log.info("자동차 등록원부(갑) 조회 성공 - 차량번호: {}", request.getVhrno()); log.info("========== 자동차 등록원부(갑) 조회 완료 =========="); diff --git a/src/main/java/go/kr/project/carInspectionPenalty/registration/service/impl/CarFfnlgTrgtServiceImpl.java b/src/main/java/go/kr/project/carInspectionPenalty/registration/service/impl/CarFfnlgTrgtServiceImpl.java index f4200ef..7652c42 100644 --- a/src/main/java/go/kr/project/carInspectionPenalty/registration/service/impl/CarFfnlgTrgtServiceImpl.java +++ b/src/main/java/go/kr/project/carInspectionPenalty/registration/service/impl/CarFfnlgTrgtServiceImpl.java @@ -2,9 +2,7 @@ package go.kr.project.carInspectionPenalty.registration.service.impl; import egovframework.constant.TaskPrcsSttsConstants; import egovframework.exception.MessageException; -import go.kr.project.api.model.VehicleApiResponseVO; -import go.kr.project.api.model.request.BasicRequest; -import go.kr.project.api.service.VehicleInfoService; +import go.kr.project.api.service.ExternalVehicleApiService; import go.kr.project.carInspectionPenalty.registration.config.CarFfnlgTxtParseConfig; import go.kr.project.carInspectionPenalty.registration.mapper.CarFfnlgTrgtMapper; import go.kr.project.carInspectionPenalty.registration.model.CarFfnlgTrgtVO; @@ -37,7 +35,7 @@ public class CarFfnlgTrgtServiceImpl extends EgovAbstractServiceImpl implements private final CarFfnlgTrgtMapper mapper; private final CarFfnlgTxtParseConfig parseConfig; - private final VehicleInfoService vehicleInfoService; + private final ExternalVehicleApiService service; private final ComparisonService comparisonService; @@ -947,33 +945,9 @@ public class CarFfnlgTrgtServiceImpl extends EgovAbstractServiceImpl implements continue; } - // 2. API 호출 (통합 조회) - BasicRequest apiRequest = new BasicRequest(); - apiRequest.setVhrno(vhclno); - // TODO : 기본적으로 검사일 기준으로 api 호출 - apiRequest.setLevyStdde(inspYmd != null ? inspYmd.replace("-", "") : ""); - //apiRequest.setLevyStdde(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))); - - VehicleApiResponseVO apiResponse = vehicleInfoService.getVehicleInfo(apiRequest); - - if (!apiResponse.isSuccess()) { - compareResult.put("success", false); - compareResult.put("message", "API 호출 실패: " + apiResponse.getMessage()); - failCount++; - compareResults.add(compareResult); - continue; - } - - // 2.5. API 호출 성공 시 히스토리 ID 업데이트 - if (apiResponse.getCarBassMatterInqireId() != null || apiResponse.getCarLedgerFrmbkId() != null) { - existingData.setCarBassMatterInqireId(apiResponse.getCarBassMatterInqireId()); - existingData.setCarLedgerFrmbkId(apiResponse.getCarLedgerFrmbkId()); - log.info("API 히스토리 ID 업데이트 - 차량번호: {}, 기본정보ID: {}, 원부ID: {}", - vhclno, apiResponse.getCarBassMatterInqireId(), apiResponse.getCarLedgerFrmbkId()); - } // 3. 비교 로직 실행 - String statusCode = comparisonService.executeComparison(existingData, apiResponse, rgtr); + String statusCode = null; // 결과 처리 if (statusCode != null) { diff --git a/src/main/java/go/kr/project/config/ProjectMapperConfig.java b/src/main/java/go/kr/project/config/ProjectMapperConfig.java index d74a9ea..6d5bb7b 100644 --- a/src/main/java/go/kr/project/config/ProjectMapperConfig.java +++ b/src/main/java/go/kr/project/config/ProjectMapperConfig.java @@ -19,10 +19,7 @@ import org.springframework.context.annotation.Configuration; */ @Configuration @MapperScan(basePackages = { - "go.kr.project.carInspectionPenalty.**.mapper", - "go.kr.project.common.mapper", - "go.kr.project.login.mapper", - "go.kr.project.system.**.mapper", + "go.kr.project.**.mapper", "egovframework.**.mapper" }) public class ProjectMapperConfig { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index dc7177a..f3d4bbe 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -138,7 +138,7 @@ file: max-size: 10 # 단일 파일 최대 크기 (MB) max-total-size: 100 # 총 파일 최대 크기 (MB) max-files: 10 # 최대 파일 개수 - allowed-extensions: hwp,jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip + allowed-extensions: txt,hwp,jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip real-file-delete: true # 실제 파일 삭제 여부 sub-dirs: bbs-notice: bbs/notice # 공지사항 sample 파일 저장 경로 @@ -153,83 +153,21 @@ juso: # ===== VMIS 통합 설정 (Dev 환경) ===== vmis: - integration: - mode: external # internal: 내부 VMIS 모듈 직접 호출, external: 외부 REST API 호출 - - # RestTemplate 설정 (모드별 분기) + # RestTemplate 설정 rest-template: - # Internal Mode용 설정 (정부 API 호출) - internal: - timeout: - connect-timeout-millis: 5000 # 연결 타임아웃 (정부 API는 빠른 응답 기대) - read-timeout-millis: 10000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # External Mode용 설정 (외부 VMIS-interface API 호출) - external: - timeout: - connect-timeout-millis: 10000 # 연결 타임아웃 (외부 서버 여유 있게) - read-timeout-millis: 12000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # Internal Mode 설정 (내부 VMIS 모듈 사용 시) - system: - infoSysId: "41-345" # 정보시스템 ID - infoSysIp: "105.19.10.135" # 시스템 IP - sigunguCode: "41460" # 시군구 코드 - departmentCode: "" # 부서 코드 - chargerId: "" # 담당자 ID - chargerIp: "" # 담당자 IP - chargerNm: "" # 담당자명 - - # GPKI 암호화 설정 (개발 환경: 비활성화) - gpki: - enabled: "N" # GPKI 사용 여부 (개발환경에서는 비활성화) - useSign: true # 서명 사용 여부 - charset: "UTF-8" # 문자셋 인코딩 - certServerId: "SVR5640020001" # 인증서 서버 ID (요청 시스템) - targetServerId: "SVR1611000006" # 대상 서버 ID (차세대교통안전공단) - ldap: true # LDAP 사용 여부 - gpkiLicPath: "C:\\GPKI\\Lic" # GPKI 라이선스 파일 경로 - certFilePath: "c:\\GPKI\\Certificate\\class1" # 인증서 파일 디렉토리 경로 - envCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.cer" # 암호화용 인증서 파일 경로 - envPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.key" # 암호화용 개인키 파일 경로 - envPrivateKeyPasswd: "*sbm204221" # 암호화용 개인키 비밀번호 - sigCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.cer" # 서명용 인증서 파일 경로 - sigPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.key" # 서명용 개인키 파일 경로 - sigPrivateKeyPasswd: "*sbm204221" # 서명용 개인키 비밀번호 - - # 정부 API 연동 설정 (개발 행정망) - # 타임아웃 설정은 공통 rest-template 설정 사용 - gov: - scheme: "http" - host: "10.188.225.94:29001" # 개발(DEV) 행정망 - basePath: "/piss/api/molit" - services: - basic: # 시군구연계 자동차기본사항조회 - path: "/SignguCarBassMatterInqireService" - cntcInfoCode: "AC1_FD11_01" - apiKey: "05e8d748fb366a0831dce71a32424460746a72d591cf483ccc130534dd51e394" - cvmisApikey: "014F9215-B6D9A3B6-4CED5225-68408C46" - ledger: # 시군구연계 자동차등록원부(갑) - path: "/SignguCarLedgerFrmbkService" - cntcInfoCode: "AC1_FD11_02" - apiKey: "1beeb01857c2e7e9b41c002b007ccb9754d9c272f66d4bb64fc45b302c69e529" - cvmisApikey: "63DF159B-7B9C64C5-86CCB15C-5F93E750" - - # External Mode 설정 (외부 REST API 사용 시) - # 타임아웃 설정은 공통 rest-template 설정 사용 + timeout: + connect-timeout-millis: 10000 # 연결 타임아웃 + read-timeout-millis: 12000 # 읽기 타임아웃 + connection-pool: + max-total: 100 # 최대 연결 수 + max-per-route: 20 # 경로당 최대 연결 수 + rate-limit: + permits-per-second: 5.0 # 초당 5건 제한 + + # External API 설정 external: api: url: - base: "http://localhost:8081/api/v1/vehicles" # VMIS-interface 서버 URL (로컬) + base: "http://localhost:8081/api/v1/vehicles" # VMIS-interface 서버 URL (개발) basic: "/basic" # 자동차기본정보 ledger: "/ledger" # 자동차등록원부 \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ba52880..f63609a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -158,82 +158,20 @@ juso: key: "devU01TX0FVVEgyMDI1MDkyMjEyMTM1NzExNjI0NzE=" url: "https://business.juso.go.kr/addrlink/addrLinkApiJsonp.do" -# ===== VMIS 통합 설정 (Dev 환경) ===== +# ===== VMIS 통합 설정 (Local 환경) ===== vmis: - integration: - mode: external # internal: 내부 VMIS 모듈 직접 호출, external: 외부 REST API 호출 - - # RestTemplate 설정 (모드별 분기) + # RestTemplate 설정 rest-template: - # Internal Mode용 설정 (정부 API 호출) - internal: - timeout: - connect-timeout-millis: 5000 # 연결 타임아웃 (정부 API는 빠른 응답 기대) - read-timeout-millis: 10000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # External Mode용 설정 (외부 VMIS-interface API 호출) - external: - timeout: - connect-timeout-millis: 10000 # 연결 타임아웃 (외부 서버 여유 있게) - read-timeout-millis: 12000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # Internal Mode 설정 (내부 VMIS 모듈 사용 시) - system: - infoSysId: "41-345" # 정보시스템 ID - infoSysIp: "105.19.10.135" # 시스템 IP - sigunguCode: "41460" # 시군구 코드 - departmentCode: "" # 부서 코드 - chargerId: "" # 담당자 ID - chargerIp: "" # 담당자 IP - chargerNm: "" # 담당자명 - - # GPKI 암호화 설정 (개발 환경: 비활성화) - gpki: - enabled: "N" # GPKI 사용 여부 (개발환경에서는 비활성화) - useSign: true # 서명 사용 여부 - charset: "UTF-8" # 문자셋 인코딩 - certServerId: "SVR5640020001" # 인증서 서버 ID (요청 시스템) - targetServerId: "SVR1611000006" # 대상 서버 ID (차세대교통안전공단) - ldap: true # LDAP 사용 여부 - gpkiLicPath: "C:\\GPKI\\Lic" # GPKI 라이선스 파일 경로 - certFilePath: "c:\\GPKI\\Certificate\\class1" # 인증서 파일 디렉토리 경로 - envCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.cer" # 암호화용 인증서 파일 경로 - envPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.key" # 암호화용 개인키 파일 경로 - envPrivateKeyPasswd: "*sbm204221" # 암호화용 개인키 비밀번호 - sigCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.cer" # 서명용 인증서 파일 경로 - sigPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.key" # 서명용 개인키 파일 경로 - sigPrivateKeyPasswd: "*sbm204221" # 서명용 개인키 비밀번호 - - # 정부 API 연동 설정 (개발 행정망) - # 타임아웃 설정은 공통 rest-template 설정 사용 - gov: - scheme: "http" - host: "10.188.225.94:29001" # 개발(DEV) 행정망 - basePath: "/piss/api/molit" - services: - basic: # 시군구연계 자동차기본사항조회 - path: "/SignguCarBassMatterInqireService" - cntcInfoCode: "AC1_FD11_01" - apiKey: "05e8d748fb366a0831dce71a32424460746a72d591cf483ccc130534dd51e394" - cvmisApikey: "014F9215-B6D9A3B6-4CED5225-68408C46" - ledger: # 시군구연계 자동차등록원부(갑) - path: "/SignguCarLedgerFrmbkService" - cntcInfoCode: "AC1_FD11_02" - apiKey: "1beeb01857c2e7e9b41c002b007ccb9754d9c272f66d4bb64fc45b302c69e529" - cvmisApikey: "63DF159B-7B9C64C5-86CCB15C-5F93E750" - - # External Mode 설정 (외부 REST API 사용 시) - # 타임아웃 설정은 공통 rest-template 설정 사용 + timeout: + connect-timeout-millis: 10000 # 연결 타임아웃 + read-timeout-millis: 12000 # 읽기 타임아웃 + connection-pool: + max-total: 100 # 최대 연결 수 + max-per-route: 20 # 경로당 최대 연결 수 + rate-limit: + permits-per-second: 5.0 # 초당 5건 제한 + + # External API 설정 external: api: url: diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml index f972bf8..856d567 100644 --- a/src/main/resources/application-prd.yml +++ b/src/main/resources/application-prd.yml @@ -138,7 +138,7 @@ file: max-size: 10 # 단일 파일 최대 크기 (MB) max-total-size: 100 # 총 파일 최대 크기 (MB) max-files: 10 # 최대 파일 개수 - allowed-extensions: hwp,jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip + allowed-extensions: txt,hwp,jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip real-file-delete: true # 실제 파일 삭제 여부 sub-dirs: bbs-notice: bbs/notice # 공지사항 sample 파일 저장 경로 @@ -155,83 +155,21 @@ juso: # ===== VMIS 통합 설정 (운영 환경) ===== # 주의: 실제 운영 키/호스트는 배포 환경 변수나 외부 설정(Secret)로 주입 권장 vmis: - integration: - mode: external # internal: 내부 VMIS 모듈 직접 호출, external: 외부 REST API 호출 - - # RestTemplate 설정 (모드별 분기) + # RestTemplate 설정 rest-template: - # Internal Mode용 설정 (정부 API 호출) - internal: - timeout: - connect-timeout-millis: 5000 # 연결 타임아웃 (정부 API는 빠른 응답 기대) - read-timeout-millis: 10000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # External Mode용 설정 (외부 VMIS-interface API 호출) - external: - timeout: - connect-timeout-millis: 10000 # 연결 타임아웃 (외부 서버 여유 있게) - read-timeout-millis: 12000 # 읽기 타임아웃 - connection-pool: - max-total: 100 # 최대 연결 수 - max-per-route: 20 # 경로당 최대 연결 수 - rate-limit: - permits-per-second: 5.0 # 초당 5건 제한 - - # Internal Mode 설정 (내부 VMIS 모듈 사용 시) - system: - infoSysId: "41-345" # 운영 실제값으로 교체 필요 - infoSysIp: "105.19.10.135" - sigunguCode: "41460" # 시군구 코드 (운영 실제값으로 교체 필요) - departmentCode: "" # 운영 실제값 - chargerId: "" - chargerIp: "" - chargerNm: "" - - # GPKI 암호화 설정 (운영 환경: 활성화) - gpki: - enabled: "Y" # GPKI 사용 여부 (운영환경에서는 활성화) - useSign: true # 서명 사용 여부 - charset: "UTF-8" # 문자셋 인코딩 - certServerId: "SVR5640020001" # 인증서 서버 ID (요청 시스템) - targetServerId: "SVR1611000006" # 대상 서버 ID (차세대교통안전공단) - ldap: true # LDAP 사용 여부 - gpkiLicPath: "C:\\GPKI\\VMIS-Lic" # GPKI 라이선스 파일 경로 - certFilePath: "c:\\GPKI\\Certificate\\class1" # 인증서 파일 디렉토리 경로 - envCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.cer" # 암호화용 인증서 파일 경로 - envPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_env.key" # 암호화용 개인키 파일 경로 - envPrivateKeyPasswd: "*sbm204221" # 암호화용 개인키 비밀번호 - sigCertFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.cer" # 서명용 인증서 파일 경로 - sigPrivateKeyFilePathName: "c:\\GPKI\\Certificate\\class1\\SVR5640020001_sig.key" # 서명용 개인키 파일 경로 - sigPrivateKeyPasswd: "*sbm204221" # 서명용 개인키 비밀번호 - - # 정부 API 연동 설정 (운영 행정망) - # 타임아웃 설정은 공통 rest-template 설정 사용 - gov: - scheme: "http" - host: "10.188.225.25:29001" # 운영 행정망 - basePath: "/piss/api/molit" - services: - basic: # 시군구연계 자동차기본사항조회 - path: "/SignguCarBassMatterInqireService" - cntcInfoCode: "AC1_FD11_01" - apiKey: "05e8d748fb366a0831dce71a32424460746a72d591cf483ccc130534dd51e394" - cvmisApikey: "014F9215-B6D9A3B6-4CED5225-68408C46" - ledger: # 시군구연계 자동차등록원부(갑) - path: "/SignguCarLedgerFrmbkService" - cntcInfoCode: "AC1_FD11_02" - apiKey: "1beeb01857c2e7e9b41c002b007ccb9754d9c272f66d4bb64fc45b302c69e529" - cvmisApikey: "63DF159B-7B9C64C5-86CCB15C-5F93E750" - - # External Mode 설정 (외부 REST API 사용 시) - # 타임아웃 설정은 공통 rest-template 설정 사용 + timeout: + connect-timeout-millis: 10000 # 연결 타임아웃 + read-timeout-millis: 12000 # 읽기 타임아웃 + connection-pool: + max-total: 100 # 최대 연결 수 + max-per-route: 20 # 경로당 최대 연결 수 + rate-limit: + permits-per-second: 5.0 # 초당 5건 제한 + + # External API 설정 external: api: url: - base: "http://localhost:18080/api/v1/vehicles" # VMIS-interface 서버 URL (로컬) + base: "http://localhost:18080/api/v1/vehicles" # VMIS-interface 서버 URL (운영) basic: "/basic" # 자동차기본정보 ledger: "/ledger" # 자동차등록원부 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fd736ff..c55512e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,7 +31,6 @@ mybatis: config-location: classpath:mybatis/mybatis-config.xml mapper-locations: - classpath:mybatis/mapper/**/*_${Globals.DbType}.xml - - classpath:mybatis/mapper/api-internal/**/*_${Globals.DbType}.xml type-aliases-package: go.kr.project.**.model,egovframework.**.model # Springdoc OpenAPI 설정 diff --git a/src/main/resources/mybatis/mapper/api-internal/CarBassMatterInqireMapper_maria.xml b/src/main/resources/mybatis/mapper/api/CarBassMatterInqireMapper_maria.xml similarity index 99% rename from src/main/resources/mybatis/mapper/api-internal/CarBassMatterInqireMapper_maria.xml rename to src/main/resources/mybatis/mapper/api/CarBassMatterInqireMapper_maria.xml index 4c2faad..bc83a04 100644 --- a/src/main/resources/mybatis/mapper/api-internal/CarBassMatterInqireMapper_maria.xml +++ b/src/main/resources/mybatis/mapper/api/CarBassMatterInqireMapper_maria.xml @@ -2,7 +2,7 @@ - +