refactor: 어플리케이션 업무코드(DAP) 전면 전환에 따른 패키지 및 모듈명 대규모 리팩토링
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.aop;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.aop
|
||||
* @className EimsMonitoringAspect
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class EimsMonitoringAspect {
|
||||
|
||||
// 포인트컷: io.shinhanlife.dap.biz.mcp.adapter.sender 패키지 내의 EimsSender를 구현한 모든 클래스의 메서드를 타겟으로 지정합니다.
|
||||
@Around("execution(* io.shinhanlife.dap.biz.mcp.adapter.sender.*EimsSender.*(..))")
|
||||
public Object monitorEimsCommunication(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
|
||||
// 1. 호출되는 클래스와 메서드 이름 추출
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
Object[] args = joinPoint.getArgs(); // 파라미터
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 2. [요청 로깅] EIMS망으로 요청이 나가기 직전
|
||||
log.info(" [EIMS 요청] {} - {}() | Params: {}", className, methodName, Arrays.toString(args));
|
||||
|
||||
try {
|
||||
// 실제 레거시 통신 로직 실행 (이 코드가 없으면 통신이 진행되지 않습니다)
|
||||
Object result = joinPoint.proceed();
|
||||
|
||||
// 3. [응답 로깅] 정상적으로 통신이 완료된 후
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.info("◀ [EIMS 응답] {} - {}() | 소요시간: {}ms | Result: {}", className, methodName, executionTime, result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
// 4. [에러 로깅] 레거시 통신 중 장애(타임아웃 등) 발생 시
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.error(" [EIMS 에러] {} - {}() | 소요시간: {}ms | Error: {}", className, methodName, executionTime, e.getMessage());
|
||||
|
||||
// 에러를 삼키지 않고 다시 던져서 기존 예외 처리 로직(서킷 브레이커 등)이 작동하게 합니다.
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.DynamicPayloadBuilder;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.DynamicSchemaValidator;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.connector
|
||||
* @className ExternalApiConnector
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ExternalApiConnector {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final DynamicSchemaValidator schemaValidator;
|
||||
private final DynamicPayloadBuilder payloadBuilder;
|
||||
|
||||
public ExternalApiConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
|
||||
this.restClient = RestClient.create();
|
||||
this.schemaValidator = schemaValidator;
|
||||
this.payloadBuilder = payloadBuilder;
|
||||
}
|
||||
|
||||
@RateLimiter(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
|
||||
@CircuitBreaker(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
|
||||
public String callExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
|
||||
|
||||
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
|
||||
StringBuilder errorLog = new StringBuilder();
|
||||
if (!schemaValidator.validate(spec, data, errorLog)) {
|
||||
String errorMsg = "API 요청 데이터 스키마 불일치 [" + apiName + "]: " + errorLog.toString();
|
||||
log.error(" {}", errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
|
||||
// 2. 페이로드 빌드
|
||||
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
|
||||
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
|
||||
|
||||
log.info(" 외부 API 호출 [{}] 시작 (FixedLength: {})", apiName, isFixedLength);
|
||||
|
||||
return restClient.post()
|
||||
.uri(endpoint)
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
}
|
||||
|
||||
public String fallbackForExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
|
||||
log.error(" [외부 API 장애] {} 호출 실패: {}", apiName, t.getMessage());
|
||||
return String.format("{\"status\":\"EXTERNAL_API_ERROR\", \"message\":\"외부 서비스 연동 중 오류 발생: %s\"}", t.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.DynamicPayloadBuilder;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.DynamicSchemaValidator;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.connector
|
||||
* @className InternalSystemConnector
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InternalSystemConnector {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final DynamicSchemaValidator schemaValidator;
|
||||
private final DynamicPayloadBuilder payloadBuilder;
|
||||
|
||||
// 생성자를 통한 의존성 주입 (Spring이 알아서 Validator와 Builder를 넣어줍니다)
|
||||
public InternalSystemConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
|
||||
this.restClient = RestClient.create();
|
||||
this.schemaValidator = schemaValidator;
|
||||
this.payloadBuilder = payloadBuilder;
|
||||
}
|
||||
|
||||
@RateLimiter(name = "internalSystem", fallbackMethod = "fallbackForInternal")
|
||||
@CircuitBreaker(name = "internalSystem", fallbackMethod = "fallbackForInternal")
|
||||
public String callInternalSystem(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
|
||||
|
||||
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
|
||||
StringBuilder errorLog = new StringBuilder();
|
||||
if (!schemaValidator.validate(spec, data, errorLog)) {
|
||||
String errorMsg = "대내외 시스템 연계 데이터 스키마 불일치 [" + targetName + "]: " + errorLog.toString();
|
||||
log.error(" {}", errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
|
||||
// 2. 페이로드 빌드 (고정장 vs JSON)
|
||||
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
|
||||
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
|
||||
|
||||
log.info(" 대내외 시스템 호출 [{}] 시작 (FixedLength: {})", targetName, isFixedLength);
|
||||
|
||||
return restClient.post()
|
||||
.uri(endpoint)
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
}
|
||||
|
||||
// 통신 장애(CircuitBreaker) 또는 허용량 초과(RateLimiter) 시 처리 로직
|
||||
public String fallbackForInternal(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
|
||||
log.error(" [대내외 시스템 장애/지연] {} 연계 실패: {}", targetName, t.getMessage());
|
||||
return String.format("{\"status\":\"INTERNAL_SYSTEM_ERROR\", \"message\":\"대내외 연계 시스템 호출 중 오류가 발생했거나 요청이 지연되었습니다. 사유: %s\"}", t.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.ResultStandardizer;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.connector
|
||||
* @className LegacyDbConnector
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LegacyDbConnector {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbcTemplate; // 동적 파라미터 바인딩을 위한 템플릿
|
||||
private final ResultStandardizer resultStandardizer;
|
||||
|
||||
@RateLimiter(name = "legacyDb", fallbackMethod = "fallbackForDb")
|
||||
@CircuitBreaker(name = "legacyDb", fallbackMethod = "fallbackForDb")
|
||||
public List<Map<String, Object>> executeDynamicQuery(String queryId, String sql, Map<String, Object> params) {
|
||||
log.info(" Legacy DB 조회 시작 [QueryID: {}]", queryId);
|
||||
|
||||
// 1. SQL 쿼리 실행 (오라클 등)
|
||||
List<Map<String, Object>> rawResults = jdbcTemplate.queryForList(sql, params);
|
||||
|
||||
// 2. 스키마 매핑 및 결과 정형화 (대문자 -> 카멜케이스 변환)
|
||||
List<Map<String, Object>> standardResults = rawResults.stream()
|
||||
.map(resultStandardizer::standardize)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info(" Legacy DB 조회 완료 ({}건 반환)", standardResults.size());
|
||||
return standardResults;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> fallbackForDb(String queryId, String sql, Map<String, Object> params, Throwable t) {
|
||||
log.error(" [Legacy DB 장애/지연] 쿼리 실행 실패 [{}]: {}", queryId, t.getMessage());
|
||||
throw new RuntimeException("레거시 DB 연동 중 오류가 발생했습니다. (QueryID: " + queryId + ")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.connector;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.sender.EimsSender;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.util.LegacyDataTransformer;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.connector
|
||||
* @className LegacyEimsConnector
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LegacyEimsConnector {
|
||||
|
||||
// 4가지 방식의 Sender를 모두 주입받습니다. (변수명이 아주 중요합니다!)
|
||||
private final EimsSender httpEimsSender; // 1~20 (EIMS API)
|
||||
private final EimsSender tcpEimsSender; // 21~30 (EIMS Socket)
|
||||
private final EimsSender jspFormEimsSender; // 31~40 (JSP Form)
|
||||
private final EimsSender jspJsonEimsSender; // 41~50 (JSP JSON)
|
||||
|
||||
private final EimsSender mciEimsSender; // 실시간 연계 (기존 HTTP/TCP 대체, 동기식 API)
|
||||
private final EimsSender mciStringEimsSender; // 실시간 연계 (String 전문 버전)
|
||||
private final EimsSender eaiEimsSender; // 비동기/대용량 연계 (배치 통신 등)
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
|
||||
|
||||
// 방어막 적용: 예외가 발생하거나 차단기가 열리면 fallbackMethod를 즉시 실행합니다!
|
||||
// 서킷 브레이커와 Rate Limiter를 동시에 적용 (둘 중 하나라도 걸리면 fallbackForEims 실행)
|
||||
@RateLimiter(name = "eims", fallbackMethod = "fallbackForEims")
|
||||
@CircuitBreaker(name = "eims", fallbackMethod = "fallbackForEims")
|
||||
// 2번 업그레이드 적용: 파라미터 기반의 스마트 캐싱 (고객의 요청 데이터가 다르면 캐시도 다르게 적용)
|
||||
@Cacheable(value = "eimsData", key = "#routingType + '-' + #interfaceId + '-' + (#data != null ? #data.hashCode() : 0)")
|
||||
public String executeByTool(String routingType, String interfaceId, Map<String, Object> data, List<Map<String, Object>> spec) throws Exception {
|
||||
|
||||
// 1. 데이터 조립 (방어 로직 포함)
|
||||
String payload = buildPayload(data, spec);
|
||||
|
||||
log.info("\n [Adapter -> Legacy] EIMS 통신 요청 - RoutingType: {}, Interface: {}, Payload: {}", routingType, interfaceId, payload);
|
||||
|
||||
// 2. 4단계 라우팅 분기 처리 (Switch Expression 활용)
|
||||
String upperRoutingType = routingType != null ? routingType.toUpperCase() : "";
|
||||
|
||||
String legacyResponse = switch (upperRoutingType) {
|
||||
case "HTTP" -> {
|
||||
log.info("[라우팅] EIMS API (HTTP) 통신으로 전달");
|
||||
yield httpEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
case "TCP" -> {
|
||||
log.info("[라우팅] EIMS 소켓 (TCP) 통신으로 전달");
|
||||
yield tcpEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
case "JSP_FORM" -> {
|
||||
log.info("[라우팅] JSP Form 통신으로 전달");
|
||||
yield jspFormEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
case "JSP_JSON" -> {
|
||||
log.info("[라우팅] JSP JSON 통신으로 전달");
|
||||
yield jspJsonEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
|
||||
case "MCI" -> {
|
||||
if (isStringMci(spec)) {
|
||||
log.info("[라우팅] 스펙 자동 판별 (String 포맷 감지) -> MCI 연계 어댑터(String)를 통해 EIMS 전달");
|
||||
yield mciStringEimsSender.send(interfaceId, payload);
|
||||
} else {
|
||||
log.info("[라우팅] 실시간 AI 요청 -> MCI 연계 어댑터를 통해 EIMS 전달");
|
||||
yield mciEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
}
|
||||
case "EAI" -> {
|
||||
log.info("[라우팅] 비동기/대용량 요청 -> EAI 연계 어댑터를 통해 EIMS 전달");
|
||||
yield eaiEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
default -> {
|
||||
log.error("[라우팅] 알 수 없는 라우팅 타입: {}", routingType);
|
||||
throw new IllegalArgumentException("지원하지 않는 라우팅 타입입니다: " + routingType);
|
||||
}
|
||||
};
|
||||
|
||||
log.info("\n [Legacy -> Adapter] EIMS 통신 응답 수신: {}", legacyResponse);
|
||||
return legacyResponse;
|
||||
}
|
||||
|
||||
|
||||
// 비상용 응답 메서드 (차단기가 열려있거나, 타임아웃/에러가 났을 때 실행됨)
|
||||
// 주의: 파라미터는 원본 메서드와 100% 똑같이 맞추고, 마지막에 Throwable을 받아야 합니다.
|
||||
public String fallbackForEims(String routingType, String interfaceId, Map<String, Object> data, List<Map<String, Object>> spec, Throwable t) {
|
||||
|
||||
// 1. Rate Limiter에 의해 차단된 경우 (트래픽 폭주)
|
||||
if (t instanceof RequestNotPermitted) {
|
||||
log.warn(" [Rate Limiter 발동] 트래픽 폭주로 요청 차단! interfaceId: {}", interfaceId);
|
||||
return String.format(
|
||||
"{\"status\":\"TOO_MANY_REQUESTS\", \"message\":\"순간적인 요청 폭주로 인해 일시적으로 제한되었습니다. 잠시 후 시도해 주세요.\", \"interfaceId\":\"%s\"}",
|
||||
interfaceId
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Circuit Breaker에 의해 차단된 경우 (레거시 시스템 장애/지연)
|
||||
log.error(" [서킷 브레이커 발동] 레거시 통신 차단! 원인: {}", t.getMessage());
|
||||
return String.format(
|
||||
"{\"status\":\"CIRCUIT_OPEN\", \"message\":\"신한라이프 내부 시스템 장애로 인해 일시적으로 차단되었습니다. 복구 후 재시도 부탁드립니다.\", \"interfaceId\":\"%s\"}",
|
||||
interfaceId
|
||||
);
|
||||
}
|
||||
|
||||
private String buildPayload(Map<String, Object> data, List<Map<String, Object>> spec) {
|
||||
// 3번 항목 적용: 원본 데이터를 스펙에 맞게 엄격히 정제(변환, 형변환, 잘라내기, 기본값 등)
|
||||
Map<String, Object> transformedData = LegacyDataTransformer.transform(data, spec);
|
||||
|
||||
if (transformedData == null || transformedData.isEmpty()) return "{}";
|
||||
|
||||
try {
|
||||
return jsonMapper.writeValueAsString(transformedData);
|
||||
} catch (Exception e) {
|
||||
log.error(" JSON 변환 에러: {}", e.getMessage());
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isStringMci(List<Map<String, Object>> spec) {
|
||||
if (spec == null || spec.isEmpty()) return false;
|
||||
// 스펙 내에 maxLength 등 고정길이 전문 관련 속성이 하나라도 존재하거나 명시적으로 STRING 힌트가 있으면 String 전문으로 간주
|
||||
return spec.stream().anyMatch(field ->
|
||||
field.containsKey("maxLength") ||
|
||||
field.containsKey("byteSize") ||
|
||||
"STRING".equalsIgnoreCase(String.valueOf(field.get("mciFormat")))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.connector;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.connector
|
||||
* @className ThirdPartySecurityConnector
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ThirdPartySecurityConnector {
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* 3rd Party 보안 모듈(DRM, BM 등)과 연동하기 위한 전용 메서드입니다.
|
||||
*/
|
||||
public String executeSecurityModule(String interfaceId, Map<String, Object> data) throws Exception {
|
||||
log.info(" [3rd Party Security] 보안 모듈 연동 시작 - Interface: {}", interfaceId);
|
||||
|
||||
// 보안 모듈 통신을 위한 특수 페이로드 조립 (예시)
|
||||
// 실제로는 RestClient나 WebClient를 통해 보안 VM의 전용 엔드포인트로 호출합니다.
|
||||
|
||||
String resultJson;
|
||||
|
||||
if (interfaceId.startsWith("DRM_")) {
|
||||
log.info(" [DRM 처리] 내부 문서 암/복호화 모듈과 통신 중...");
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "SUCCESS",
|
||||
"module", "DRM",
|
||||
"message", "문서 보안 처리가 완료되었습니다.",
|
||||
"interfaceId", interfaceId,
|
||||
"data", data != null ? data : Map.of()
|
||||
));
|
||||
} else if (interfaceId.startsWith("BM_")) {
|
||||
log.info(" [BM 처리] 바이오 인증 모듈과 통신 중...");
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "SUCCESS",
|
||||
"module", "Bio-Metric",
|
||||
"message", "바이오 인증이 완료되었습니다.",
|
||||
"interfaceId", interfaceId,
|
||||
"data", data != null ? data : Map.of()
|
||||
));
|
||||
} else {
|
||||
log.warn(" 알 수 없는 보안 모듈 연동 요청: {}", interfaceId);
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "UNKNOWN_MODULE",
|
||||
"message", "알 수 없는 보안 모듈 인터페이스입니다."
|
||||
));
|
||||
}
|
||||
|
||||
log.info(" [3rd Party Security] 보안 모듈 처리 완료");
|
||||
return resultJson;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.exception;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.exception
|
||||
* @className MciCommunicationException
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public class MciCommunicationException extends RuntimeException {
|
||||
|
||||
public MciCommunicationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MciCommunicationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.TicketManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className EaiEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("eaiEimsSender")
|
||||
@RequiredArgsConstructor
|
||||
public class EaiEimsSender implements EimsSender {
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
private final XmlMapper xmlMapper;
|
||||
|
||||
// 실전 코드: 스프링이 제공하는 카프카 템플릿 주입
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
|
||||
// 비동기 티켓 매니저
|
||||
private final TicketManager ticketManager;
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String jsonPayload) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch(); stopWatch.start();
|
||||
|
||||
try {
|
||||
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
|
||||
String xmlData = xmlMapper.writeValueAsString(jsonNode);
|
||||
String esbStandardXml = wrapWithEaiHeader(interfaceId, xmlData);
|
||||
|
||||
log.info(" [EAI 어댑터] Kafka 토픽(eai-topic)으로 전송 시도...");
|
||||
|
||||
try {
|
||||
// 실전 코드 적용: Kafka로 메시지 발행
|
||||
kafkaTemplate.send("eai-topic", esbStandardXml);
|
||||
log.info(" [EAI 어댑터] Kafka 전송 완료!");
|
||||
} catch (Exception e) {
|
||||
// 로컬 환경에는 카프카가 없으므로 에러가 날 수 있습니다. 테스트를 위해 로깅만 하고 넘깁니다.
|
||||
log.warn(" 로컬 환경이거나 Ka<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
||||
"<EaiMessage>\n" +
|
||||
" <Header>\n" +
|
||||
" <ChannelId>MCP_GATEWAY_ASYNC</ChannelId>\n" +
|
||||
" <InterfaceId>EAI_BATCH_JOB</InterfaceId>\n" +
|
||||
" <Timestamp>1782705901850</Timestamp>\n" +
|
||||
" <TransferType>ASYNC</TransferType>\n" +
|
||||
" </Header>\n" +
|
||||
" <Body>\n" +
|
||||
" <ObjectNode>\n" +
|
||||
" <batchId>BATCH_20260629_001</batchId>\n" +
|
||||
" <targetSystem>GLOBAL_MINIMUM_TAX_SYS</targetSystem>\n" +
|
||||
" <recordCount>50000</recordCount>\n" +
|
||||
" </ObjectNode>\n" +
|
||||
" </Body>\n" +
|
||||
"</EaiMessage>fka 서버에 연결할 수 없습니다. (메시지 출력으로 대체합니다) \n전송하려던 메시지: {}", esbStandardXml);
|
||||
}
|
||||
|
||||
// 비동기 폴링을 위한 티켓 발급
|
||||
String ticketId = ticketManager.issueTicket(interfaceId, jsonPayload);
|
||||
|
||||
return String.format(
|
||||
"{\"status\":\"PROCESSING\", \"interfaceId\":\"%s\", \"ticketId\":\"%s\", \"message\":\"비동기 작업이 접수되었습니다. 상태 조회 API를 통해 결과를 확인하세요.\"}",
|
||||
interfaceId, ticketId);
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info(" [SLA 모니터링 - EAI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapWithEaiHeader(String interfaceId, String xmlData) {
|
||||
// 비동기 EAI는 추적을 위해 TransferType이나 Batch ID 같은 속성이 추가로 들어가는 경우가 많습니다.
|
||||
return String.format(
|
||||
"<EaiMessage>" +
|
||||
"<Header>" +
|
||||
"<ChannelId>MCP_GATEWAY_ASYNC</ChannelId>" +
|
||||
"<InterfaceId>%s</InterfaceId>" +
|
||||
"<Timestamp>%d</Timestamp>" +
|
||||
"<TransferType>ASYNC</TransferType>" +
|
||||
"</Header>" +
|
||||
"<Body>%s</Body>" +
|
||||
"</EaiMessage>",
|
||||
interfaceId, System.currentTimeMillis(), xmlData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className EimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface EimsSender {
|
||||
// 프로토콜에 상관없이 이 메서드 하나로 통일합니다.
|
||||
String send(String interfaceId, String payload) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.tool.config.GlowCommunicationProperties;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className HttpEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HttpEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String eimsUrl;
|
||||
private final GlowCommunicationProperties glowProps;
|
||||
|
||||
public HttpEimsSender(GlowCommunicationProperties glowProps) {
|
||||
this.glowProps = glowProps;
|
||||
// yml의 대내 MCI host, port, uri를 조합하여 EIMS 호출 주소 생성
|
||||
this.eimsUrl = glowProps.getMci().getHost() + ":" + glowProps.getMci().getPort() + glowProps.getMci().getUri();
|
||||
|
||||
/*
|
||||
*********************************************** 중요 **************************************************
|
||||
this.restClient = RestClient.create();
|
||||
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
|
||||
*********************************************** 중요 **************************************************
|
||||
*/
|
||||
|
||||
// HTTP/2 통신 시 Stream Cancelled(RST_STREAM) 에러 방지를 위해 HTTP/1.1 전용 Factory 사용
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
|
||||
this.restClient = RestClient.builder().requestFactory(factory).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) {
|
||||
log.info(" [HTTP 모드] EIMS API 호출 중... URL: {}", eimsUrl);
|
||||
|
||||
// EIMS가 요구하는 JSON 포맷으로 래핑해서 전송 (EIMS 규격에 따라 수정 가능)
|
||||
Map<String, String> requestBody = Map.of(
|
||||
"interfaceId", interfaceId,
|
||||
"data", payload
|
||||
);
|
||||
|
||||
// 4번 항목 적용: MDC에 저장된 traceId를 추출하여 HTTP Header(X-Trace-Id)로 전파
|
||||
String traceId = MDC.get("traceId");
|
||||
if (traceId == null) traceId = "SYSTEM-GENERATED-" + UUID.randomUUID().toString();
|
||||
|
||||
return restClient.post()
|
||||
.uri(eimsUrl)
|
||||
.header("X-Trace-Id", traceId)
|
||||
.header("X-Shinhan-Global-ID", traceId)
|
||||
.body(requestBody)
|
||||
.retrieve()
|
||||
.body(String.class); // 응답 결과를 String으로 받음
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className JspFormEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JspFormEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String jspUrl;
|
||||
|
||||
public JspFormEimsSender(@Value("${eims.jsp.form.url}") String jspUrl) {
|
||||
this.jspUrl = jspUrl;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) {
|
||||
log.info(" [JSP Form 모드] 레거시 폼 데이터 전송 중... URL: {}", jspUrl);
|
||||
|
||||
// 1. Form Data 조립 (HTML <form> 태그 전송과 동일한 효과)
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("interfaceId", interfaceId);
|
||||
formData.add("data", payload);
|
||||
|
||||
// 2. HTTP 전송 (Content-Type: application/x-www-form-urlencoded)
|
||||
String rawResponse = restClient.post()
|
||||
.uri(jspUrl)
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(formData)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
// 3. JSP 특유의 앞뒤 공백 및 엔터 제거
|
||||
return rawResponse != null ? rawResponse.trim() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className JspJsonEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JspJsonEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String jspUrl;
|
||||
|
||||
// 여기서 eims.jsp.json.url 딱 하나만 깔끔하게 받아옵니다!
|
||||
public JspJsonEimsSender(@Value("${eims.jsp.json.url}") String jspUrl) {
|
||||
this.jspUrl = jspUrl;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) {
|
||||
log.info(" [JSP JSON 모드] JSON 페이로드 전송 중... URL: {}", jspUrl);
|
||||
|
||||
// 1. JSON 객체로 조립 (스프링이 알아서 JSON String으로 변환해 줌)
|
||||
Map<String, String> jsonBody = Map.of(
|
||||
"interfaceId", interfaceId,
|
||||
"data", payload
|
||||
);
|
||||
|
||||
// 2. HTTP 전송 (Content-Type: application/json)
|
||||
String rawResponse = restClient.post()
|
||||
.uri(jspUrl)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(jsonBody)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
// 3. JSP 응답 정제
|
||||
return rawResponse != null ? rawResponse.trim() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className MciEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("mciEimsSender")
|
||||
public class MciEimsSender implements EimsSender {
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
private final XmlMapper xmlMapper;
|
||||
private final RestClient restClient; // Spring Boot 3.2+ 최신 HTTP 클라이언트
|
||||
private final String mciUrl;
|
||||
|
||||
public MciEimsSender(ObjectMapper jsonMapper, XmlMapper xmlMapper, @Value("${eims.mci.url}") String mciUrl) {
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.xmlMapper = xmlMapper;
|
||||
this.mciUrl = mciUrl;
|
||||
this.restClient = RestClient.create(); // 클라이언트 초기화
|
||||
|
||||
/*
|
||||
*********************************************** 중요 **************************************************
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restClient = RestClient.builder().requestFactory(factory).build();
|
||||
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
|
||||
*********************************************** 중요 **************************************************
|
||||
*/
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String jsonPayload) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch(); stopWatch.start();
|
||||
|
||||
try {
|
||||
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
|
||||
String xmlData = xmlMapper.writer().withRootName("Body").writeValueAsString(jsonNode);
|
||||
String esbStandardXml = wrapWithEsbHeader(interfaceId, xmlData);
|
||||
|
||||
log.info(" [ESB 어댑터] 전송 준비 완료 - RestClient 호출 시작");
|
||||
|
||||
String responseXml = restClient.post()
|
||||
.uri(mciUrl)
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(esbStandardXml)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
log.info(" [ESB 어댑터] 응답 수신 완료: {}", responseXml);
|
||||
|
||||
JsonNode responseNode = xmlMapper.readTree(responseXml);
|
||||
return jsonMapper.writeValueAsString(responseNode);
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info(" [SLA 모니터링 - MCI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapWithEsbHeader(String interfaceId, String xmlData) {
|
||||
return String.format("<EsbMessage><Header><InterfaceId>%s</InterfaceId></Header><Body>%s</Body></EsbMessage>", interfaceId, xmlData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className MciStringEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("mciStringEimsSender")
|
||||
public class MciStringEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String mciUrl;
|
||||
|
||||
public MciStringEimsSender(@Value("${eims.mcistring.url}") String mciUrl) {
|
||||
this.mciUrl = mciUrl;
|
||||
this.restClient = RestClient.create();
|
||||
/*
|
||||
*********************************************** 중요 **************************************************
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restClient = RestClient.builder().requestFactory(factory).build();
|
||||
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
|
||||
*********************************************** 중요 **************************************************
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch(); stopWatch.start();
|
||||
|
||||
try {
|
||||
log.info(" [ESB 어댑터(String)] 전송 준비 완료 - RestClient 호출 시작 (Interface: {})", interfaceId);
|
||||
|
||||
String response = restClient.post()
|
||||
.uri(mciUrl)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body(payload != null ? payload : "")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
log.info(" [ESB 어댑터(String)] 응답 수신 완료: {}", response);
|
||||
return response != null ? response : "";
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info(" [SLA 모니터링 - MCI(String)] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.common.integration.mci.config.ShinhanIntegrationProperties;
|
||||
import io.shinhanlife.dap.common.integration.mci.dto.MciRequestWrapper;
|
||||
import io.shinhanlife.dap.common.integration.mci.dto.ShinhanCommonHeaderDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className ShinhanMciSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@EnableConfigurationProperties(ShinhanIntegrationProperties.class)
|
||||
public class ShinhanMciSender {
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
private final RestClient restClient;
|
||||
private final ShinhanIntegrationProperties properties;
|
||||
private final AtomicInteger sequenceGenerator = new AtomicInteger(1);
|
||||
|
||||
public ShinhanMciSender(ObjectMapper jsonMapper, ShinhanIntegrationProperties properties) {
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.properties = properties;
|
||||
this.restClient = RestClient.create();
|
||||
}
|
||||
|
||||
public <T> String send(String targetUrl, MciRequestWrapper<T> requestWrapper) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start();
|
||||
|
||||
try {
|
||||
ShinhanCommonHeaderDto header = requestWrapper.getTgrmCmnnhddValu();
|
||||
if (header == null) {
|
||||
header = new ShinhanCommonHeaderDto();
|
||||
requestWrapper.setTgrmCmnnhddValu(header);
|
||||
}
|
||||
|
||||
// 필수 헤더 자동 세팅 로직
|
||||
header.setEnvrTypeCd(properties.getEnvrTypeCd());
|
||||
header.setReqRspnScCd("S"); // S: 요청
|
||||
header.setAppliDutjCd("DAP"); // 어플리케이션업무코드
|
||||
|
||||
if (header.getGlbId() == null || header.getGlbId().isEmpty()) {
|
||||
header.setGlbId(generateGlbId());
|
||||
}
|
||||
|
||||
int currentSeq = sequenceGenerator.getAndIncrement();
|
||||
header.setPgrsSriaNo(String.format("%03d", currentSeq)); // 3자리
|
||||
|
||||
if (header.getReqTgrmTnsmDtptDt() == null) {
|
||||
header.setReqTgrmTnsmDtptDt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")));
|
||||
}
|
||||
|
||||
String jsonPayload = jsonMapper.writeValueAsString(requestWrapper);
|
||||
|
||||
log.info(" [신한 통합 MCI 어댑터] 전송 준비 완료 - URL: {}", targetUrl);
|
||||
log.debug(" [신한 통합 MCI 어댑터] 요청 Payload: {}", jsonPayload);
|
||||
|
||||
String responseJson = restClient.post()
|
||||
.uri(targetUrl)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(jsonPayload)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
log.info(" [신한 통합 MCI 어댑터] 응답 수신 완료");
|
||||
log.debug(" [신한 통합 MCI 어댑터] 응답 Payload: {}", responseJson);
|
||||
|
||||
return responseJson;
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info(" [SLA 모니터링 - MCI 연계] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private String generateGlbId() {
|
||||
// 전사공통키 (37 Byte)
|
||||
// 전문생성상세일시(17) + 전문생성시스템명(9) + 어플리케이션업무코드(3) + 전문세션번호(8)
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); // 17 byte
|
||||
String systemName = String.format("%-9s", "AXHUB"); // 9 byte, left-aligned padded with spaces
|
||||
String appCode = "DAP"; // 3 byte
|
||||
String sessionNo = UUID.randomUUID().toString().substring(0, 8).toUpperCase(); // 8 byte
|
||||
return timestamp + systemName + appCode + sessionNo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className TcpEimsSender
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TcpEimsSender implements EimsSender {
|
||||
|
||||
@Value("${eims.tcp.host}")
|
||||
private String host;
|
||||
|
||||
@Value("${eims.tcp.port}")
|
||||
private int port;
|
||||
|
||||
@Value("${eims.tcp.timeout}")
|
||||
private int timeout;
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) throws Exception {
|
||||
log.info(" [TCP 소켓 모드] EIMS 접속 중... {}:{}", host, port);
|
||||
|
||||
// TCP 소켓 자원을 사용 후 안전하게 닫아주는 try-with-resources 구문
|
||||
try (Socket socket = new Socket()) {
|
||||
// 1. 타임아웃 및 연결 설정
|
||||
socket.connect(new InetSocketAddress(host, port), timeout);
|
||||
socket.setSoTimeout(timeout); // 읽기 타임아웃
|
||||
|
||||
OutputStream os = socket.getOutputStream();
|
||||
InputStream is = socket.getInputStream();
|
||||
|
||||
// 2. 데이터 송신 (EUC-KR 인코딩 필수)
|
||||
// 보통 금융권 TCP 통신은 맨 앞에 전체 길이나 인터페이스 ID를 헤더로 붙입니다.
|
||||
String sendData = interfaceId + payload;
|
||||
os.write(sendData.getBytes("EUC-KR"));
|
||||
os.flush();
|
||||
|
||||
// 3. 데이터 수신
|
||||
byte[] buffer = new byte[4096];
|
||||
int readByte = is.read(buffer);
|
||||
|
||||
if (readByte == -1) {
|
||||
throw new RuntimeException("EIMS 서버가 응답 없이 연결을 종료했습니다.");
|
||||
}
|
||||
|
||||
// 받은 바이트를 다시 한글(EUC-KR) 문자열로 복원
|
||||
return new String(buffer, 0, readByte, "EUC-KR");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className DynamicPayloadBuilder
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
public class DynamicPayloadBuilder {
|
||||
|
||||
public String buildFixedLengthString(List<Map<String, Object>> specList, Map<String, Object> data) throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (Map<String, Object> spec : specList) {
|
||||
String name = (String) spec.get("name");
|
||||
// EIMS가 고정장을 요구할 경우를 대비한 len 파라미터 체크 (기본값 0 방어)
|
||||
int len = spec.get("len") != null ? (Integer) spec.get("len") : 0;
|
||||
String type = (String) spec.get("type");
|
||||
String rawValue = String.valueOf(data.getOrDefault(name, ""));
|
||||
|
||||
// len 값이 없으면 변환 없이 바로 이어붙임 (JSON 통신용)
|
||||
if (len == 0) {
|
||||
sb.append(rawValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
// len 값이 있으면 고정장 통신 규칙 적용
|
||||
byte[] rawBytes = rawValue.getBytes("EUC-KR");
|
||||
if (rawBytes.length > len) {
|
||||
throw new IllegalArgumentException(name + " 길이가 " + len + " 바이트를 초과합니다.");
|
||||
}
|
||||
|
||||
int padLength = len - rawBytes.length;
|
||||
StringBuilder paddedValue = new StringBuilder(rawValue);
|
||||
|
||||
if ("NUMBER".equalsIgnoreCase(type)) {
|
||||
for (int i = 0; i < padLength; i++) paddedValue.insert(0, "0");
|
||||
} else {
|
||||
for (int i = 0; i < padLength; i++) paddedValue.append(" ");
|
||||
}
|
||||
sb.append(paddedValue.toString());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className DynamicSchemaValidator
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
public class DynamicSchemaValidator {
|
||||
|
||||
public boolean validate(List<Map<String, Object>> specList, Map<String, Object> data, StringBuilder errorLog) {
|
||||
for (Map<String, Object> spec : specList) {
|
||||
String name = (String) spec.get("name");
|
||||
String type = (String) spec.get("type");
|
||||
boolean isRequired = spec.get("required") != null && (Boolean) spec.get("required");
|
||||
Object value = data.get(name);
|
||||
|
||||
// 1. 필수값 체크
|
||||
if (isRequired && (value == null || String.valueOf(value).trim().isEmpty())) {
|
||||
errorLog.append(String.format("[%s] 필드는 필수 입력 항목입니다. ", name));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 타입 체크 (값이 있을 때만)
|
||||
if (value != null && !String.valueOf(value).trim().isEmpty()) {
|
||||
if ("NUMBER".equalsIgnoreCase(type) && !String.valueOf(value).matches("-?\\d+(\\.\\d+)?")) {
|
||||
errorLog.append(String.format("[%s] 필드는 숫자여야 합니다. ", name));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.exception.MciCommunicationException;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.sender.EimsSender;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className MciTemplate
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MciTemplate {
|
||||
|
||||
private final EimsSender httpEimsSender;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* MCI 인터페이스를 호출하고 결과를 지정된 타입으로 반환합니다.
|
||||
*
|
||||
* @param interfaceId 호출할 MCI 인터페이스 ID
|
||||
* @param requestDto 요청 데이터 객체
|
||||
* @param responseType 응답을 매핑할 클래스 타입
|
||||
* @param <T> 요청 객체 타입
|
||||
* @param <R> 응답 객체 타입
|
||||
* @return 매핑된 응답 객체
|
||||
* @throws MciCommunicationException 통신 또는 파싱 실패 시 예외 발생
|
||||
*/
|
||||
public <T, R> R call(String interfaceId, T requestDto, Class<R> responseType) {
|
||||
log.info(" [MciTemplate] 시작 - Interface ID: {}", interfaceId);
|
||||
|
||||
try {
|
||||
String payload = objectMapper.writeValueAsString(requestDto);
|
||||
log.debug(" [MciTemplate] 전송 페이로드: {}", payload);
|
||||
|
||||
String responseJson = httpEimsSender.send(interfaceId, payload);
|
||||
log.debug(" [MciTemplate] 수신 응답 JSON: {}", responseJson);
|
||||
|
||||
R response = objectMapper.readValue(responseJson, responseType);
|
||||
log.info(" [MciTemplate] 완료 - Interface ID: {}", interfaceId);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error(" [MciTemplate] JSON 변환 중 오류 발생", e);
|
||||
throw new MciCommunicationException("MCI 통신 중 JSON 파싱 오류", e);
|
||||
} catch (Exception e) {
|
||||
log.error(" [MciTemplate] 통신 중 오류 발생", e);
|
||||
throw new MciCommunicationException("MCI 통신 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className ResultStandardizer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Component
|
||||
public class ResultStandardizer {
|
||||
|
||||
// 오라클 조회 결과(Map)의 키 값을 카멜 케이스로 변환 (스키마 매핑)
|
||||
public Map<String, Object> standardize(Map<String, Object> rawData) {
|
||||
Map<String, Object> standardMap = new HashMap<>();
|
||||
|
||||
rawData.forEach((key, value) -> {
|
||||
String camelKey = convertToCamelCase(key);
|
||||
standardMap.put(camelKey, value);
|
||||
});
|
||||
|
||||
return standardMap;
|
||||
}
|
||||
|
||||
private String convertToCamelCase(String snakeCase) {
|
||||
if (snakeCase == null || snakeCase.isEmpty()) return snakeCase;
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean nextIsUpper = false;
|
||||
|
||||
for (char c : snakeCase.toLowerCase().toCharArray()) {
|
||||
if (c == '_') {
|
||||
nextIsUpper = true;
|
||||
} else {
|
||||
result.append(nextIsUpper ? Character.toUpperCase(c) : c);
|
||||
nextIsUpper = false;
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className TicketManager
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TicketManager {
|
||||
|
||||
// 로컬 시뮬레이션을 위한 인메모리 저장소 (운영 환경에서는 Redis 등을 사용)
|
||||
private final Map<String, Ticket> ticketStore = new ConcurrentHashMap<>();
|
||||
|
||||
// 가짜 EAI 작업(10초 대기)을 실행할 백그라운드 쓰레드 풀
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
|
||||
|
||||
/**
|
||||
* 새로운 비동기 작업을 접수하고 티켓을 발급합니다.
|
||||
*/
|
||||
public String issueTicket(String interfaceId, String payload) {
|
||||
String ticketId = "TICKET-" + UUID.randomUUID().toString();
|
||||
|
||||
Ticket ticket = new Ticket();
|
||||
ticket.setTicketId(ticketId);
|
||||
ticket.setStatus("PROCESSING");
|
||||
ticket.setMessage("EAI 시스템에서 데이터 처리 중입니다...");
|
||||
|
||||
ticketStore.put(ticketId, ticket);
|
||||
|
||||
log.info(" [TicketManager] 비동기 작업 티켓 발급 완료: {}", ticketId);
|
||||
|
||||
// 10초 뒤에 자동으로 작업을 완료 상태로 변경하는 백그라운드 시뮬레이터 실행
|
||||
simulateEaiProcessing(ticketId, interfaceId);
|
||||
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 티켓의 현재 상태를 조회합니다.
|
||||
*/
|
||||
public Ticket getTicketStatus(String ticketId) {
|
||||
return ticketStore.getOrDefault(ticketId, new Ticket("NOT_FOUND", "해당 티켓을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 10초 뒤에 상태를 COMPLETED로 변경하여 진짜 EAI 배치가 끝난 것처럼 흉내냅니다.
|
||||
*/
|
||||
private void simulateEaiProcessing(String ticketId, String interfaceId) {
|
||||
scheduler.schedule(() -> {
|
||||
Ticket ticket = ticketStore.get(ticketId);
|
||||
if (ticket != null) {
|
||||
ticket.setStatus("COMPLETED");
|
||||
ticket.setMessage("EAI 배치가 정상적으로 완료되었습니다.");
|
||||
// 가짜 최종 결과 데이터 주입
|
||||
ticket.setResultData("{\"resultCode\":\"0000\", \"interfaceId\":\"" + interfaceId + "\", \"processedRecords\":50000}");
|
||||
ticketStore.put(ticketId, ticket);
|
||||
log.info(" [TicketManager] EAI 비동기 작업 시뮬레이션 완료! (Ticket: {})", ticketId);
|
||||
}
|
||||
}, 10, TimeUnit.SECONDS); // 10초 지연
|
||||
}
|
||||
|
||||
// 티켓 상태를 담을 내부 DTO 클래스
|
||||
@lombok.Data
|
||||
public static class Ticket {
|
||||
private String ticketId;
|
||||
private String status; // PROCESSING, COMPLETED, FAILED, NOT_FOUND
|
||||
private String message;
|
||||
private String resultData; // 최종 완료 시 담길 데이터
|
||||
|
||||
public Ticket() {}
|
||||
public Ticket(String status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.support;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.ErrorDetail;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.Params;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.support
|
||||
* @className ToolExecutionService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ToolExecutionService {
|
||||
|
||||
// 방어막(서킷/캐시)이 적용된 커넥터 주입
|
||||
private final LegacyEimsConnector legacyEimsConnector;
|
||||
|
||||
/**
|
||||
* AI Agent가 호출한 툴을 실제 레거시 커넥터로 전달합니다.
|
||||
*/
|
||||
public JsonRpcResponse executeTool(JsonRpcRequest request) {
|
||||
Params params = request.getParams();
|
||||
|
||||
// 1. 필수 파라미터 검증
|
||||
if (params == null || params.getName() == null) {
|
||||
return createErrorResponse(request.getId(), -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Connector의 executeByTool 메서드 호출
|
||||
// [참고] connector에 선언된 파라미터 구조에 맞게 매핑합니다.
|
||||
String result = legacyEimsConnector.executeByTool(
|
||||
params.getRoutingType(),
|
||||
params.getInterfaceId(),
|
||||
params.getData(),
|
||||
params.getSpec()
|
||||
);
|
||||
|
||||
// 3. 성공 응답 생성 (result가 JSON 문자열일 경우, 실제 DTO로 변환하여 반환하면 더 좋습니다)
|
||||
return createSuccessResponse(request.getId(), result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error(" [ToolExecution] 커넥터 호출 실패: RoutingType={}, Error={}", params.getRoutingType(), e.getMessage());
|
||||
return createErrorResponse(request.getId(), -32000, "Connector error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Agent를 위한 툴 목록 스키마 반환
|
||||
*/
|
||||
public JsonRpcResponse getToolList(String requestId) {
|
||||
// 기존에 정의한 Tool 스키마 리스트 반환 로직...
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// ... (Tool 명세 내용)
|
||||
return createSuccessResponse(requestId, result);
|
||||
}
|
||||
|
||||
private JsonRpcResponse createSuccessResponse(String id, Object result) {
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(id);
|
||||
response.setResult(result);
|
||||
return response;
|
||||
}
|
||||
|
||||
private JsonRpcResponse createErrorResponse(String id, int code, String message) {
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(id);
|
||||
response.setError(new ErrorDetail(code, message));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.test;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.test
|
||||
* @className MockEimsHttpServer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class MockEimsHttpServer {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MockEimsHttpServer(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/gateway")
|
||||
public Map<String, Object> mockEimsReceiver(
|
||||
@RequestHeader(value = "X-Trace-Id", required = false) String traceId,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
|
||||
String interfaceId = (String) request.get("interfaceId");
|
||||
String data = (String) request.get("data");
|
||||
|
||||
log.info(" [가짜 EIMS 서버] HTTP 요청 수신 완료!");
|
||||
log.info(" 수신된 Trace-ID (거래고유번호): {}", traceId != null ? traceId : "없음");
|
||||
log.info(" 인터페이스ID: {}, 데이터: [{}]", interfaceId, data);
|
||||
|
||||
// No-Code Mock 응답 생성 (JSON 설정 파일 기반)
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource("mock-responses.json");
|
||||
Map<String, Object> mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class);
|
||||
|
||||
if (mockDataMap.containsKey(interfaceId)) {
|
||||
return (Map<String, Object>) mockDataMap.get(interfaceId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON 파싱 에러 또는 mock 파일 읽기 실패 (기본 응답 반환)", e);
|
||||
}
|
||||
|
||||
// 기본 응답
|
||||
return Map.of(
|
||||
"status", "404",
|
||||
"message", "MOCK 데이터가 정의되지 않았습니다.",
|
||||
"receivedLength", data.length()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/mock/esb/api")
|
||||
public String mockEsbReceiver(@RequestBody String xmlPayload) {
|
||||
log.info(" [가짜 ESB 서버] MCI/ESB 요청 수신 완료!");
|
||||
log.info(" 수신된 XML 전문: {}", xmlPayload);
|
||||
|
||||
// MciEimsSender가 기대하는 JSON 변환용 XML 포맷 응답
|
||||
return "<Response><status>SUCCESS</status><message>MOCK_MCI_EIMS_RECEIVE_SUCCESS</message><data><info>정상 처리되었습니다.</info></data></Response>";
|
||||
}
|
||||
|
||||
@PostMapping("/mock/esb/string")
|
||||
public String mockEsbStringReceiver(@RequestBody(required = false) String payload) {
|
||||
log.info(" [가짜 ESB 서버] MCI String 요청 수신 완료!");
|
||||
log.info(" 수신된 String 전문: {}", payload);
|
||||
|
||||
// MciSampleStringRes 에 맞게 고정 길이 응답 생성
|
||||
// name (10), age (3), joinDate (8), statusCode (2)
|
||||
// targetList (30) -> MciSampleTargetDto (itemCode 5, itemValue 5) x 3
|
||||
return "홍길동 03020260901OKA0001B0001A0002B0002A0003B0003";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.test;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
// 대신 "local" 환경(application-local.properties)에서만 가짜 소켓 서버가 켜지도록 보장합니다.
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.test
|
||||
* @className MockEimsTcpServer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Profile("local")
|
||||
public class MockEimsTcpServer {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MockEimsTcpServer(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Value("${eims.tcp.port:8090}")
|
||||
private int port;
|
||||
|
||||
@Value("${server.port:8081}")
|
||||
private int serverPort;
|
||||
|
||||
private ServerSocket serverSocket;
|
||||
private boolean running = true;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void startTcpServer() {
|
||||
// MSA 환경에서는 각 툴 Pod 내부에서 독립적인 가짜 EIMS 서버가 실행되도록 허용
|
||||
log.info(" [가짜 EIMS 서버] 로컬 테스트 환경용 EIMS TCP 서버 가동을 준비합니다.");
|
||||
|
||||
// Java 21 가상 스레드를 사용하여 메인 서버 가동에 방해 없이 백그라운드에서 실행
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
serverSocket = new ServerSocket(port);
|
||||
log.info(" [가짜 EIMS 서버] 로컬 TCP 소켓 서버 가동 완료 (Port: {})", port);
|
||||
|
||||
while (running) {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
|
||||
// 연결된 요청을 별도 스레드로 처리
|
||||
Thread.ofVirtual().start(() -> handleClient(clientSocket));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (running) log.error(" 가짜 TCP 서버 에러: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleClient(Socket socket) {
|
||||
try (socket) {
|
||||
InputStream is = socket.getInputStream();
|
||||
OutputStream os = socket.getOutputStream();
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int readByte = is.read(buffer);
|
||||
|
||||
if (readByte != -1) {
|
||||
String receivedData = new String(buffer, 0, readByte, "EUC-KR");
|
||||
log.info(" [가짜 EIMS 서버] TCP 데이터 수신 완료!");
|
||||
log.info(" 수신된 전문: [{}]", receivedData);
|
||||
|
||||
String responseData = "{\"status\":\"404\",\"message\":\"MOCK 데이터가 정의되지 않았습니다.\"}";
|
||||
try {
|
||||
int braceIndex = receivedData.indexOf('{');
|
||||
String interfaceId = "";
|
||||
if (braceIndex > 0) {
|
||||
interfaceId = receivedData.substring(0, braceIndex).trim();
|
||||
} else if (braceIndex == -1) {
|
||||
interfaceId = receivedData.trim();
|
||||
}
|
||||
|
||||
ClassPathResource resource = new ClassPathResource("mock-responses.json");
|
||||
Map<String, Object> mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class);
|
||||
|
||||
if (mockDataMap.containsKey(interfaceId)) {
|
||||
responseData = objectMapper.writeValueAsString(mockDataMap.get(interfaceId));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("TCP JSON 파싱 에러 또는 파일 읽기 실패", e);
|
||||
}
|
||||
|
||||
// 응답 데이터 송신
|
||||
os.write(responseData.getBytes("EUC-KR"));
|
||||
os.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(" 가짜 TCP 클라이언트 처리 에러: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stopTcpServer() {
|
||||
this.running = false;
|
||||
try {
|
||||
if (serverSocket != null) serverSocket.close();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.test;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.test
|
||||
* @className MockJspServer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/mock")
|
||||
public class MockJspServer {
|
||||
|
||||
// ==========================================
|
||||
// 1. Form Data 방식 테스트 수신부 (jsp-form)
|
||||
// ==========================================
|
||||
@PostMapping(value = "/jsp-form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
public String mockJspFormReceiver(
|
||||
@RequestParam("interfaceId") String interfaceId,
|
||||
@RequestParam("data") String data) {
|
||||
|
||||
log.info(" [가짜 JSP 서버] 폼 데이터(Form) 수신 완료!");
|
||||
log.info(" 파라미터 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
|
||||
|
||||
// 실제 JSP 서버처럼 앞뒤에 의미 없는 줄바꿈(엔터)과 공백을 잔뜩 넣어서 리턴합니다.
|
||||
return "\n\n SUCCESS_FROM_MOCK_JSP_FORM \n\n";
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. JSON 방식 테스트 수신부 (jsp-json)
|
||||
// ==========================================
|
||||
@PostMapping(value = "/jsp-json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public String mockJspJsonReceiver(@RequestBody Map<String, String> request) {
|
||||
|
||||
String interfaceId = request.get("interfaceId");
|
||||
String data = request.get("data");
|
||||
|
||||
log.info(" [가짜 JSP 서버] 제이슨(JSON) 수신 완료!");
|
||||
log.info(" JSON 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
|
||||
|
||||
// 여기도 마찬가지로 쓰레기 여백을 넣어줍니다.
|
||||
return "\n\n SUCCESS_FROM_MOCK_JSP_JSON \n\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.util
|
||||
* @className LegacyDataTransformer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
public class LegacyDataTransformer {
|
||||
|
||||
/**
|
||||
* AI 모델이 보낸 자유로운 형태의 data를 레거시 시스템 규격(spec)에 맞게 강제 변환합니다.
|
||||
*
|
||||
* @param data AI가 보낸 파라미터 맵
|
||||
* @param spec 레거시 시스템이 요구하는 파라미터 스펙 (name, type, maxLength, defaultValue, required 등)
|
||||
* @return 엄격하게 정제된 파라미터 맵
|
||||
*/
|
||||
public static Map<String, Object> transform(Map<String, Object> data, List<Map<String, Object>> spec) {
|
||||
if (spec == null || spec.isEmpty()) {
|
||||
return data != null ? data : new HashMap<>();
|
||||
}
|
||||
|
||||
Map<String, Object> transformedData = new HashMap<>();
|
||||
|
||||
for (Map<String, Object> fieldSpec : spec) {
|
||||
String fieldName = (String) fieldSpec.get("name");
|
||||
if (fieldName == null) continue;
|
||||
|
||||
Object rawValue = data != null ? data.get(fieldName) : null;
|
||||
Object finalValue = rawValue;
|
||||
|
||||
// 1. 기본값(Default Value) 주입
|
||||
if (finalValue == null && fieldSpec.containsKey("defaultValue")) {
|
||||
finalValue = fieldSpec.get("defaultValue");
|
||||
log.debug(" [DataTransformer] '{}' 필드 누락 -> 기본값 '{}' 주입", fieldName, finalValue);
|
||||
}
|
||||
|
||||
// 2. 강제 형변환 (Type Coercion)
|
||||
String type = (String) fieldSpec.getOrDefault("type", "string");
|
||||
if (finalValue != null) {
|
||||
if ("string".equalsIgnoreCase(type) && !(finalValue instanceof String)) {
|
||||
finalValue = String.valueOf(finalValue);
|
||||
log.debug(" [DataTransformer] '{}' 필드 강제 String 형변환", fieldName);
|
||||
} else if ("number".equalsIgnoreCase(type) && finalValue instanceof String) {
|
||||
try {
|
||||
finalValue = Long.parseLong((String) finalValue);
|
||||
log.debug(" [DataTransformer] '{}' 필드 강제 Number 형변환", fieldName);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn(" [DataTransformer] '{}' 필드 Number 형변환 실패. 기존 값 유지", fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 길이 제한 (Truncation / MaxLength)
|
||||
if (finalValue instanceof String && fieldSpec.containsKey("maxLength")) {
|
||||
int maxLength = (Integer) fieldSpec.get("maxLength");
|
||||
String strVal = (String) finalValue;
|
||||
if (strVal.length() > maxLength) {
|
||||
finalValue = strVal.substring(0, maxLength);
|
||||
log.warn(" [DataTransformer] '{}' 필드 길이 초과! {}자로 강제 자름 (Truncated)", fieldName, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 필수값(Required) 누락 체크 (에러를 던지지 않고 빈 문자열 강제 주입하여 레거시 팅김 방지)
|
||||
boolean isRequired = (Boolean) fieldSpec.getOrDefault("required", false);
|
||||
if (isRequired && finalValue == null) {
|
||||
log.error(" [DataTransformer] 필수 필드 '{}' 누락! 강제 공백 주입하여 시스템 장애 방지", fieldName);
|
||||
finalValue = "string".equalsIgnoreCase(type) ? "" : 0;
|
||||
}
|
||||
|
||||
if (finalValue != null) {
|
||||
transformedData.put(fieldName, finalValue);
|
||||
}
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.util;
|
||||
|
||||
import ch.qos.logback.classic.pattern.MessageConverter;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
|
||||
/**
|
||||
* Logback 커스텀 컨버터
|
||||
* 모든 로그 메시지(%msg)가 파일이나 콘솔에 찍히기 직전에 이 클래스를 거쳐가게 됩니다.
|
||||
* 여기서 PiiMaskingUtils.mask()를 호출하여 PII(주민번호, 계좌번호 등)를 안전하게 별표(*) 처리합니다.
|
||||
*/
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.util
|
||||
* @className PiiMaskingLogbackConverter
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public class PiiMaskingLogbackConverter extends MessageConverter {
|
||||
|
||||
@Override
|
||||
public String convert(ILoggingEvent event) {
|
||||
// 원본 로그 메시지를 가져옵니다.
|
||||
String originalMessage = super.convert(event);
|
||||
|
||||
// 정규식을 이용하여 개인정보가 포함되어 있으면 마스킹 처리하여 반환합니다.
|
||||
return PiiMaskingUtils.mask(originalMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.util;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.util
|
||||
* @className PiiMaskingUtils
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public class PiiMaskingUtils {
|
||||
|
||||
// 1. 주민등록번호 패턴 (ex: 900101-1234567 또는 9001011234567)
|
||||
private static final Pattern RRN_PATTERN = Pattern.compile("(\\d{6})[-]?([1-4]\\d{6})");
|
||||
|
||||
// 2. 휴대전화번호 패턴 (ex: 010-1234-5678)
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("(01[016789])[-]?(\\d{3,4})[-]?(\\d{4})");
|
||||
|
||||
// 3. 신한라이프 계좌/증권번호 패턴 (단순 예시용 계좌번호 11~14자리)
|
||||
private static final Pattern ACCOUNT_PATTERN = Pattern.compile("(\\d{3})-?(\\d{3})-?(\\d{5,8})");
|
||||
|
||||
public static String mask(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
|
||||
String masked = input;
|
||||
|
||||
// [1] 주민번호 뒷자리 마스킹 (첫자리 성별 식별자는 남기고 마스킹: 900101-1******)
|
||||
Matcher rrnMatcher = RRN_PATTERN.matcher(masked);
|
||||
StringBuffer rrnBuffer = new StringBuffer();
|
||||
while (rrnMatcher.find()) {
|
||||
String firstPart = rrnMatcher.group(1);
|
||||
String secondPart = rrnMatcher.group(2);
|
||||
rrnMatcher.appendReplacement(rrnBuffer, firstPart + "-" + secondPart.charAt(0) + "******");
|
||||
}
|
||||
rrnMatcher.appendTail(rrnBuffer);
|
||||
masked = rrnBuffer.toString();
|
||||
|
||||
// [2] 전화번호 중간자리 마스킹 (010-****-5678)
|
||||
Matcher phoneMatcher = PHONE_PATTERN.matcher(masked);
|
||||
StringBuffer phoneBuffer = new StringBuffer();
|
||||
while (phoneMatcher.find()) {
|
||||
String p1 = phoneMatcher.group(1);
|
||||
String p2 = phoneMatcher.group(2);
|
||||
String p3 = phoneMatcher.group(3);
|
||||
String maskedP2 = p2.replaceAll(".", "*");
|
||||
phoneMatcher.appendReplacement(phoneBuffer, p1 + "-" + maskedP2 + "-" + p3);
|
||||
}
|
||||
phoneMatcher.appendTail(phoneBuffer);
|
||||
masked = phoneBuffer.toString();
|
||||
|
||||
// [3] 계좌번호 뒷자리 마스킹 (110-123-********)
|
||||
Matcher accMatcher = ACCOUNT_PATTERN.matcher(masked);
|
||||
StringBuffer accBuffer = new StringBuffer();
|
||||
while (accMatcher.find()) {
|
||||
String a1 = accMatcher.group(1);
|
||||
String a2 = accMatcher.group(2);
|
||||
String a3 = accMatcher.group(3);
|
||||
String maskedA3 = a3.replaceAll(".", "*");
|
||||
accMatcher.appendReplacement(accBuffer, a1 + "-" + a2 + "-" + maskedA3);
|
||||
}
|
||||
accMatcher.appendTail(accBuffer);
|
||||
masked = accBuffer.toString();
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.converter;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleAiReqDto;
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleLegacyReqDto;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface SampleLegacyConverter {
|
||||
|
||||
// 테스트 환경 등에서 Spring Bean 주입 없이 직접 접근하기 위한 INSTANCE 제공 (IDE 에러 방지용)
|
||||
SampleLegacyConverter INSTANCE = Mappers.getMapper(SampleLegacyConverter.class);
|
||||
|
||||
/**
|
||||
* AI Agent의 DTO를 MCI 통신용 DTO로 변환합니다.
|
||||
* 필드명이 달라도 @Mapping 어노테이션으로 손쉽게 연결할 수 있습니다.
|
||||
*/
|
||||
@Mapping(source = "userId", target = "customerId")
|
||||
@Mapping(source = "actionType", target = "interfaceId")
|
||||
@Mapping(source = "extraInfo", target = "requestDetails")
|
||||
SampleLegacyReqDto toLegacyReq(SampleAiReqDto aiReq);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
public class SampleAiReqDto {
|
||||
/**
|
||||
* AI Agent가 전달하는 자연어 기반의 고객 식별자
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* AI Agent가 판별한 액션 유형
|
||||
*/
|
||||
private String actionType;
|
||||
|
||||
/**
|
||||
* AI Agent가 추출한 부가 정보
|
||||
*/
|
||||
private String extraInfo;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.dto;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.sample.dto
|
||||
* @className SampleMciReqDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
public class SampleLegacyReqDto {
|
||||
|
||||
/**
|
||||
* MCI 인터페이스 ID (예: MCI0001)
|
||||
*/
|
||||
private String interfaceId;
|
||||
|
||||
/**
|
||||
* 고객 식별 번호
|
||||
*/
|
||||
private String customerId;
|
||||
|
||||
/**
|
||||
* 요청 세부 파라미터
|
||||
*/
|
||||
private String requestDetails;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.dto;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.sample.dto
|
||||
* @className SampleMciResDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
public class SampleLegacyResDto {
|
||||
|
||||
/**
|
||||
* 응답 코드 (예: "0000"이면 성공)
|
||||
*/
|
||||
private String resCode;
|
||||
|
||||
/**
|
||||
* 응답 메시지
|
||||
*/
|
||||
private String resMsg;
|
||||
|
||||
/**
|
||||
* 상세 데이터
|
||||
*/
|
||||
private Map<String, Object> data;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.service;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.support.MciTemplate;
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleLegacyReqDto;
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleLegacyResDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.sample.service
|
||||
* @className SampleLegacyCallService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SampleLegacyCallService {
|
||||
|
||||
private final MciTemplate mciTemplate;
|
||||
|
||||
/**
|
||||
* MCI 인터페이스를 호출하는 샘플 메서드 (MciTemplate 시범 적용)
|
||||
*
|
||||
* @param request MCI 호출을 위한 요청 데이터
|
||||
* @return MCI 서버의 응답 데이터
|
||||
*/
|
||||
public SampleLegacyResDto callSampleMci(SampleLegacyReqDto request) {
|
||||
// 기존 20줄 이상의 통신 로직이 단 1줄로 완벽하게 은닉화 되었습니다.
|
||||
return mciTemplate.call(request.getInterfaceId(), request, SampleLegacyResDto.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface McpFunction {
|
||||
String displayName(); // 사람이 읽는 라벨 (예: "고객 조회 툴")
|
||||
String name(); // MCP 서브툴 명칭 (예: "customer_search")
|
||||
String description();
|
||||
String prompt() default "";
|
||||
String mappingId() default "";
|
||||
|
||||
// 추가: 해당 함수가 요구하는 비즈니스 파라미터(JSON 형태의 properties)를 정의
|
||||
String inputSchema() default "{}";
|
||||
|
||||
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
|
||||
boolean register() default true;
|
||||
|
||||
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
|
||||
boolean visible() default true;
|
||||
|
||||
// 추가: HITL 승인 체계 지원 (실행 전 사용자 승인 필요 여부)
|
||||
boolean requiresApproval() default false;
|
||||
|
||||
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface McpParameter {
|
||||
String description();
|
||||
boolean required() default false;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.annotation;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface McpTool {
|
||||
@AliasFor(annotation = Component.class)
|
||||
String value() default "";
|
||||
|
||||
String categoryKey() default "common";
|
||||
String routingType() default "HTTP";
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.aop;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.config.McpProperties;
|
||||
import java.lang.reflect.Method;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StopWatch;
|
||||
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolSlaMonitoringAspect {
|
||||
|
||||
private final McpProperties mcpProperties;
|
||||
|
||||
// @McpFunction 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
|
||||
@Around("@annotation(McpFunction)")
|
||||
public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
McpFunction functionAnnotation = method.getAnnotation(McpFunction.class);
|
||||
|
||||
// 네임스페이스 자동 주입 로직을 반영하여 최종 툴 이름을 산출합니다.
|
||||
String baseName = functionAnnotation.name();
|
||||
String finalName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
|
||||
? mcpProperties.getNamespace() + "_" + baseName
|
||||
: baseName;
|
||||
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start();
|
||||
|
||||
try {
|
||||
// 실제 비즈니스 로직(툴) 실행
|
||||
Object result = joinPoint.proceed();
|
||||
|
||||
stopWatch.stop();
|
||||
long timeMillis = stopWatch.getTotalTimeMillis();
|
||||
|
||||
// SLA 기준을 초과하면 (예: 2초 이상) 경고 로깅 처리 가능
|
||||
if (timeMillis > 2000) {
|
||||
log.warn(" [SLA 경고] Tool: {} | 소요시간: {}ms | 상태: SLOW_RESPONSE", finalName, timeMillis);
|
||||
} else {
|
||||
log.info(" [SLA 추적] Tool: {} | 소요시간: {}ms | 상태: SUCCESS", finalName, timeMillis);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Throwable e) {
|
||||
if (stopWatch.isRunning()) {
|
||||
stopWatch.stop();
|
||||
}
|
||||
long timeMillis = stopWatch.getTotalTimeMillis();
|
||||
|
||||
// 에러 발생 시 명확하게 실패 로그 기록
|
||||
log.error(" [SLA 장애] Tool: {} | 소요시간: {}ms | 상태: FAILED | 사유: {}", finalName, timeMillis, e.getMessage());
|
||||
|
||||
// 원래 흐름대로 예외를 던져서 게이트웨이나 상위 로직이 에러를 처리하게 함
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* [Glow Framework 통신 환경 설정 클래스]
|
||||
* application-glow-local.yml 의 'glow.communication' 하위 설정값들을
|
||||
* 자바 객체(Bean)로 매핑하여 제공합니다.
|
||||
*/
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.tool.config
|
||||
* @className GlowCommunicationProperties
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Component
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "glow.communication")
|
||||
public class GlowCommunicationProperties {
|
||||
|
||||
private Common common = new Common();
|
||||
private Http http = new Http();
|
||||
private Mci mci = new Mci();
|
||||
private ExtMci extmci = new ExtMci();
|
||||
private Eai eai = new Eai();
|
||||
private Websocket websocket = new Websocket();
|
||||
|
||||
@Getter @Setter
|
||||
public static class Common {
|
||||
private String envType; // 대내표준 헤더의 환경 타입정보 (D, T, P)
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Http {
|
||||
private int connectionTimeout; // 연결 타임아웃 시간 (초 단위)
|
||||
private int readTimeout; // 읽기 타임아웃 시간 (초 단위)
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Mci {
|
||||
private String host;
|
||||
private int port;
|
||||
private String uri;
|
||||
private String receiveUri;
|
||||
private int connectionTimeout;
|
||||
private int readTimeout;
|
||||
private String encoding;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class ExtMci {
|
||||
private String host;
|
||||
private int port;
|
||||
private String uri;
|
||||
private String jsonUri;
|
||||
private String receiveUri;
|
||||
private int connectionTimeout;
|
||||
private int readTimeout;
|
||||
private String encoding;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Eai {
|
||||
private String host;
|
||||
private int port;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Websocket {
|
||||
private String endpoint;
|
||||
private String allowedOrigins;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.tool.config
|
||||
* @className McpProperties
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "mcp")
|
||||
public class McpProperties {
|
||||
|
||||
private String namespace;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.tool.dto
|
||||
* @className ToolMetadata
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ToolMetadata {
|
||||
// 1. Tool 기본 정보
|
||||
private String uid; // UUID 형식의 고유 식별자
|
||||
private String semver; // 버전 (예: 1.0.0)
|
||||
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
||||
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
||||
|
||||
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
|
||||
private Map<String, Object> parametersSchema;
|
||||
|
||||
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
|
||||
private Map<String, String> actionPrompts;
|
||||
|
||||
// 2-1. 도메인 부서 그룹명 (category_key, 슬러그 형식)
|
||||
private String categoryKey;
|
||||
private String endpoint;
|
||||
private String podUrl;
|
||||
private String integrationType;
|
||||
private String mciServiceId;
|
||||
|
||||
|
||||
@Builder.Default
|
||||
private Boolean visible = true;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean isRegistered = true;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean requiresApproval = false;
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.presentation;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.networknt.schema.JsonSchema;
|
||||
import com.networknt.schema.JsonSchemaFactory;
|
||||
import com.networknt.schema.SpecVersion;
|
||||
import com.networknt.schema.ValidationMessage;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.service.ToolRegistryHeartbeatSender;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.util.JsonSchemaGenerator;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/")
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessToolController {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final McpProperties mcpProperties;
|
||||
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
|
||||
|
||||
// 내부 조회용 로컬 Tool 목록 엔드포인트
|
||||
@GetMapping("/mcp/api/v1/tools/local")
|
||||
public List<ToolMetadata> getLocalTools() {
|
||||
return toolRegistryHeartbeatSender.getAllScannedTools();
|
||||
}
|
||||
|
||||
// JSON RPC 기반 단일 라우팅 엔드포인트
|
||||
@PostMapping("/mcp/api/v1/tools/call")
|
||||
public ResponseEntity<Map<String, Object>> executeDynamicTool(
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String headerRequestId,
|
||||
@RequestBody(required = false) Map<String, Object> payload) {
|
||||
|
||||
String finalRequestId = headerRequestId != null ? headerRequestId : (payload != null && payload.get("id") != null ? String.valueOf(payload.get("id")) : null);
|
||||
Map<String, Object> params = payload != null ? (Map<String, Object>) payload.get("params") : null;
|
||||
String functionName = params != null ? (String) params.get("name") : null;
|
||||
|
||||
log.info("\n [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName);
|
||||
if (payload != null) {
|
||||
try {
|
||||
log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(payload));
|
||||
} catch (Exception e) {
|
||||
log.info(" [Tool] 호출 파라미터: {}", payload);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : null;
|
||||
Object targetBean = null;
|
||||
Method targetMethod = null;
|
||||
McpFunction targetFunctionAnnotation = null;
|
||||
|
||||
// McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpFunction을 찾습니다.
|
||||
Map<String, Object> allBeans = applicationContext.getBeansOfType(Object.class);
|
||||
outerLoop:
|
||||
for (Object bean : allBeans.values()) {
|
||||
Class<?> targetClass = AopUtils.getTargetClass(bean);
|
||||
for (Method method : targetClass.getDeclaredMethods()) {
|
||||
McpFunction mcpFunc = AnnotationUtils.findAnnotation(method, McpFunction.class);
|
||||
if (mcpFunc != null) {
|
||||
String baseName = mcpFunc.name();
|
||||
String expectedName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
|
||||
? mcpProperties.getNamespace() + "_" + baseName
|
||||
: baseName;
|
||||
|
||||
if (expectedName.equals(functionName) || baseName.equals(functionName)) {
|
||||
targetBean = bean;
|
||||
targetMethod = method;
|
||||
targetFunctionAnnotation = mcpFunc;
|
||||
break outerLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetBean == null || targetMethod == null) {
|
||||
List<String> availableFunctions = new ArrayList<>();
|
||||
for (Object bean : allBeans.values()) {
|
||||
Class<?> targetCls = AopUtils.getTargetClass(bean);
|
||||
for (Method m : targetCls.getDeclaredMethods()) {
|
||||
McpFunction func = AnnotationUtils.findAnnotation(m, McpFunction.class);
|
||||
if (func != null) {
|
||||
String baseName = func.name();
|
||||
String expName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
|
||||
? mcpProperties.getNamespace() + "_" + baseName : baseName;
|
||||
availableFunctions.add(expName + " (in " + targetCls.getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}. 현재 스캔된 툴 메서드 목록: {}", functionName, availableFunctions);
|
||||
Map<String, Object> errorDetails = new HashMap<>();
|
||||
errorDetails.put("status", "404");
|
||||
Map<String, Object> errorBody = new HashMap<>();
|
||||
errorBody.put("code", "TOOL_NOT_FOUND");
|
||||
errorBody.put("message", "실행할 함수를 찾을 수 없습니다: " + functionName);
|
||||
errorBody.put("details", errorDetails);
|
||||
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
|
||||
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("jsonrpc", "2.0");
|
||||
error.put("error", errorBody);
|
||||
error.put("id", payload != null ? payload.get("id") : null);
|
||||
return ResponseEntity.status(404).body(error);
|
||||
}
|
||||
// (기존 차단 로직 제거됨)
|
||||
|
||||
// 2. 파라미터 유효성 검증 (JSON Schema)
|
||||
if (targetMethod.getParameterCount() > 0) {
|
||||
Class<?> paramType = targetMethod.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
try {
|
||||
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
|
||||
String fullSchemaJson = "{\"type\":\"object\", \"properties\":" + objectMapper.writeValueAsString(autoSchema) + "}";
|
||||
|
||||
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
|
||||
JsonSchema schema = factory.getSchema(fullSchemaJson);
|
||||
|
||||
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
|
||||
if (!errors.isEmpty()) {
|
||||
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
|
||||
List<String> errorMessages = new ArrayList<>();
|
||||
for (ValidationMessage vm : errors) {
|
||||
errorMessages.add(vm.getMessage());
|
||||
}
|
||||
Map<String, Object> errorDetails = new HashMap<>();
|
||||
errorDetails.put("status", "422");
|
||||
Map<String, Object> errorBody = new HashMap<>();
|
||||
errorBody.put("code", "INVALID_PARAM");
|
||||
errorBody.put("message", "파라미터 유효성 검증 실패");
|
||||
errorBody.put("details", errorDetails);
|
||||
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
|
||||
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("jsonrpc", "2.0");
|
||||
error.put("error", errorBody);
|
||||
error.put("id", payload != null ? payload.get("id") : null);
|
||||
return ResponseEntity.status(422).body(error);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName());
|
||||
|
||||
try {
|
||||
// 3. DTO 파라미터 자동 매핑 (Map -> DTO)
|
||||
Object invokeArgument = arguments;
|
||||
if (targetMethod.getParameterCount() > 0 && arguments != null) {
|
||||
Class<?> paramType = targetMethod.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
invokeArgument = objectMapper.convertValue(arguments, paramType);
|
||||
log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 메서드 실행
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
Object methodResult = null;
|
||||
methodResult = targetMethod.invoke(targetBean, invokeArgument);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 5. 결과 조립 (JSON-RPC 응답 - Agent Builder 규격 적용)
|
||||
Map<String, Object> innerResult = new HashMap<>();
|
||||
if (methodResult instanceof Map && ((Map<?, ?>) methodResult).containsKey("contracts")) {
|
||||
innerResult.putAll((Map<String, Object>) methodResult);
|
||||
} else {
|
||||
List<Object> contracts = new ArrayList<>();
|
||||
if (methodResult != null) {
|
||||
contracts.add(methodResult);
|
||||
}
|
||||
innerResult.put("contracts", contracts);
|
||||
|
||||
if (methodResult instanceof Map && ((Map<?, ?>) methodResult).containsKey("status")) {
|
||||
innerResult.put("status", ((Map<?, ?>) methodResult).get("status"));
|
||||
} else {
|
||||
innerResult.put("status", "SUCCESS");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> resultPayload = new HashMap<>();
|
||||
resultPayload.put("status", "ok");
|
||||
resultPayload.put("result", innerResult);
|
||||
resultPayload.put("error_code", null);
|
||||
resultPayload.put("error_message", null);
|
||||
resultPayload.put("elapsed_ms", elapsed);
|
||||
resultPayload.put("truncated", false);
|
||||
int originalSize = 0;
|
||||
try {
|
||||
if (methodResult instanceof Map && ((Map<?, ?>) methodResult).containsKey("legacy_response")) {
|
||||
Object legacyResp = ((Map<?, ?>) methodResult).get("legacy_response");
|
||||
if (legacyResp instanceof String) {
|
||||
originalSize = ((String) legacyResp).getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
}
|
||||
} else {
|
||||
String jsonStr = objectMapper.writeValueAsString(innerResult);
|
||||
originalSize = jsonStr.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to calculate original_size", e);
|
||||
}
|
||||
resultPayload.put("original_size", originalSize);
|
||||
|
||||
Map<String, Object> rpcResponse = new LinkedHashMap<>(); // 순서 보장을 위해 LinkedHashMap 사용
|
||||
rpcResponse.put("jsonrpc", "2.0");
|
||||
rpcResponse.put("result", resultPayload);
|
||||
rpcResponse.put("id", payload != null ? payload.get("id") : null);
|
||||
|
||||
try {
|
||||
log.info("[Tool -> MCP Gateway] 동적 툴 실행 결과 반환: {}", objectMapper.writeValueAsString(rpcResponse));
|
||||
} catch (Exception e) {
|
||||
log.info("[Tool -> MCP Gateway] 동적 툴 실행 결과 반환: {}", rpcResponse);
|
||||
}
|
||||
|
||||
|
||||
return ResponseEntity.ok(rpcResponse);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage());
|
||||
Map<String, Object> errorDetails = new HashMap<>();
|
||||
errorDetails.put("status", "500");
|
||||
Map<String, Object> errorBody = new HashMap<>();
|
||||
errorBody.put("code", "TOOL_ERROR");
|
||||
errorBody.put("message", "Tool execution failed");
|
||||
errorDetails.clear(); // Hide details for upstream errors
|
||||
errorBody.put("details", errorDetails);
|
||||
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
|
||||
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("jsonrpc", "2.0");
|
||||
error.put("error", errorBody);
|
||||
error.put("id", payload != null ? payload.get("id") : null);
|
||||
return ResponseEntity.status(502).body(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.util.PiiMaskingUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.tool.service
|
||||
* @className AbstractMcpToolService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class AbstractMcpToolService {
|
||||
|
||||
@Autowired
|
||||
protected LegacyEimsConnector legacyEimsConnector;
|
||||
|
||||
@Autowired
|
||||
protected ObjectMapper objectMapper;
|
||||
|
||||
protected Map<String, Object> executeLegacy(String routingType, String interfaceId, Object inputData) {
|
||||
return executeLegacy(routingType, interfaceId, inputData, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 시스템을 호출하고 공통 처리(PII 마스킹 등)를 수행합니다. (스펙 지정 가능)
|
||||
*/
|
||||
protected Map<String, Object> executeLegacy(String routingType, String interfaceId, Object inputData, List<Map<String, Object>> spec) {
|
||||
Map<String, Object> inputMap;
|
||||
if (inputData == null) {
|
||||
inputMap = new HashMap<>();
|
||||
} else if (inputData instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> map = (Map<String, Object>) inputData;
|
||||
inputMap = map;
|
||||
} else {
|
||||
inputMap = objectMapper.convertValue(inputData, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("\n=======================================================");
|
||||
log.info(" [Tool -> Legacy] 레거시 시스템 통신 시작");
|
||||
log.info(" - Routing Type: {}", routingType);
|
||||
log.info(" - Interface ID: {}", interfaceId);
|
||||
log.info(" - 호출 파라미터 (Request): \n{}", objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(inputMap));
|
||||
log.info("=======================================================\n");
|
||||
|
||||
// 1. Adapter 공통 모듈 직접 호출
|
||||
String executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, inputMap, spec);
|
||||
|
||||
log.info("\n=======================================================");
|
||||
log.info(" [Legacy -> Tool] 레거시 시스템 통신 완료");
|
||||
log.info(" - 응답 파라미터 (Response, 마스킹 전): \n{}", executionResult);
|
||||
log.info("=======================================================\n");
|
||||
|
||||
// 2. PII 마스킹 처리
|
||||
String maskedResult = PiiMaskingUtils.mask(executionResult);
|
||||
|
||||
if (executionResult != null && (executionResult.contains("\"status\":\"CIRCUIT_OPEN\"") || executionResult.contains("\"status\":\"TOO_MANY_REQUESTS\""))) {
|
||||
try {
|
||||
return objectMapper.readValue(executionResult, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("Fallback JSON 파싱 에러: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("status", "SUCCESS");
|
||||
result.put("legacy_response", maskedResult);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", e.getMessage());
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.tool.util.JsonSchemaGenerator;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@RequiredArgsConstructor
|
||||
public class ToolRegistryHeartbeatSender {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final McpProperties mcpProperties;
|
||||
private final RestClient restClient = RestClient.create();
|
||||
|
||||
@Value("${axhub.gateway.url:http://localhost:8081}")
|
||||
private String gatewayUrl;
|
||||
|
||||
@Value("${axhub.tool.url:http://localhost:8080}")
|
||||
private String podUrl;
|
||||
|
||||
private List<ToolMetadata> registeredTools = new ArrayList<>();
|
||||
|
||||
@Getter
|
||||
private List<ToolMetadata> allScannedTools = new ArrayList<>();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info(" [HeartbeatSender] 초기화 시작. Gateway URL: {}, Pod URL: {}", gatewayUrl, podUrl);
|
||||
scanAndBuildMetadata();
|
||||
}
|
||||
|
||||
private void scanAndBuildMetadata() {
|
||||
Map<String, Object> allBeans = applicationContext.getBeansOfType(Object.class);
|
||||
for (Object bean : allBeans.values()) {
|
||||
Class<?> targetClass = AopUtils.getTargetClass(bean);
|
||||
McpTool toolAnnotation = AnnotationUtils.findAnnotation(targetClass, McpTool.class);
|
||||
|
||||
for (Method method : targetClass.getDeclaredMethods()) {
|
||||
McpFunction functionAnnotation = AnnotationUtils.findAnnotation(method, McpFunction.class);
|
||||
if (functionAnnotation != null) {
|
||||
String baseName = functionAnnotation.displayName();
|
||||
String rawSubToolName = functionAnnotation.name();
|
||||
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
|
||||
? mcpProperties.getNamespace() + "_" + rawSubToolName
|
||||
: rawSubToolName;
|
||||
|
||||
boolean isRegister = functionAnnotation.register();
|
||||
if (!isRegister) {
|
||||
log.info(" [HeartbeatSender] '{}' 툴은 어노테이션 설정에 의해 외부 등록(Redis) 대상에서 제외되었습니다. (최종 이름: {})", baseName, subToolName);
|
||||
}
|
||||
|
||||
ToolMetadata meta = new ToolMetadata();
|
||||
meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString());
|
||||
meta.setDisplayName(baseName);
|
||||
meta.setName(subToolName);
|
||||
meta.setSemver("1.0.0");
|
||||
meta.setDescription(functionAnnotation.description());
|
||||
meta.setCategoryKey(toolAnnotation.categoryKey());
|
||||
meta.setIntegrationType(toolAnnotation.routingType());
|
||||
meta.setMciServiceId(functionAnnotation.mappingId());
|
||||
meta.setPodUrl(podUrl);
|
||||
|
||||
boolean isVisible = functionAnnotation.visible();
|
||||
meta.setVisible(isVisible);
|
||||
meta.setIsRegistered(isRegister);
|
||||
meta.setRequiresApproval(functionAnnotation.requiresApproval());
|
||||
|
||||
Map<String, String> prompts = new HashMap<>();
|
||||
String promptText = functionAnnotation.prompt();
|
||||
prompts.put(subToolName, promptText);
|
||||
meta.setActionPrompts(prompts);
|
||||
|
||||
if (method.getParameterCount() > 0) {
|
||||
try {
|
||||
Class<?> paramType = method.getParameterTypes()[0];
|
||||
Map<String, Object> schema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
|
||||
Map<String, Object> finalSchema = new HashMap<>();
|
||||
finalSchema.put("type", "object");
|
||||
finalSchema.put("properties", schema);
|
||||
meta.setParametersSchema(finalSchema);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate schema for {}", subToolName, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRegister) {
|
||||
registeredTools.add(meta);
|
||||
}
|
||||
allScannedTools.add(meta);
|
||||
log.info(" [HeartbeatSender] 도구 메타데이터 생성: {} (isRegistered: {})", meta.getUid(), isRegister);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30000)
|
||||
public void sendHeartbeats() {
|
||||
if (registeredTools.isEmpty()) return;
|
||||
|
||||
for (ToolMetadata tool : registeredTools) {
|
||||
try {
|
||||
ResponseEntity<String> response = restClient.post()
|
||||
.uri(gatewayUrl + "/mcp/api/v1/registry/heartbeat")
|
||||
.header("Content-Type", "application/json")
|
||||
|
||||
.body(tool.getUid())
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
log.info(" [HeartbeatSender] 하트비트 전송 성공: {}", tool.getUid());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(" [HeartbeatSender] 하트비트 전송 실패 ({}): {}. 재등록을 시도합니다.", tool.getUid(), e.getMessage());
|
||||
registerTool(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void registerTool(ToolMetadata tool) {
|
||||
try {
|
||||
restClient.post()
|
||||
.uri(gatewayUrl + "/mcp/api/v1/registry/register")
|
||||
.header("Content-Type", "application/json")
|
||||
|
||||
.body(tool)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
log.info(" [HeartbeatSender] 툴 재등록 성공: {}", tool.getUid());
|
||||
} catch (Exception ex) {
|
||||
log.error(" [HeartbeatSender] 툴 등록 실패: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.shinhanlife.dap.biz.mcp.tool.util;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.tool.annotation.McpParameter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class JsonSchemaGenerator {
|
||||
|
||||
/**
|
||||
* Java DTO 클래스를 분석하여 MCP 규격의 JSON Schema (Properties)를 생성합니다.
|
||||
*/
|
||||
public static Map<String, Object> generatePropertiesSchema(Class<?> clazz) {
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
Map<String, Object> fieldSchema = new HashMap<>();
|
||||
|
||||
// 1. 타입 매핑
|
||||
fieldSchema.put("type", mapJavaTypeToJsonType(field.getType()));
|
||||
|
||||
// 2. 어노테이션 기반 설명 추출
|
||||
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
|
||||
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
|
||||
fieldSchema.put("description", paramAnnotation.description());
|
||||
} else {
|
||||
fieldSchema.put("description", field.getName()); // 기본값
|
||||
}
|
||||
|
||||
properties.put(field.getName(), fieldSchema);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static String mapJavaTypeToJsonType(Class<?> clazz) {
|
||||
if (clazz == String.class) return "string";
|
||||
if (clazz == Integer.class || clazz == int.class) return "integer";
|
||||
if (clazz == Long.class || clazz == long.class) return "integer";
|
||||
if (clazz == Double.class || clazz == double.class) return "number";
|
||||
if (clazz == Float.class || clazz == float.class) return "number";
|
||||
if (clazz == Boolean.class || clazz == boolean.class) return "boolean";
|
||||
if (List.class.isAssignableFrom(clazz)) return "array";
|
||||
return "object";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# ==============================================================================
|
||||
# [AXHub 공통 환경 설정 파일] (Glow Framework 연동용)
|
||||
# 이 파일은 axhub-tool-core에 위치하며, 각 툴 파드들이 상속받아 사용합니다.
|
||||
# ==============================================================================
|
||||
|
||||
# 1. 로깅(Logging) 공통 설정
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
io.shinhanlife.axhub: DEBUG
|
||||
io.shinhanlife.glow: INFO
|
||||
org.springframework.web: INFO
|
||||
pattern:
|
||||
console: "[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] %logger{36} - %msg%n"
|
||||
|
||||
# 2. Redis 공통 설정 (Gateway 연동 및 캐싱)
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1 # TODO: 실제 Redis 망 IP로 변경
|
||||
port: 6379 # TODO: 실제 Redis 포트로 변경
|
||||
password: "" # TODO: 실제 비밀번호 입력
|
||||
|
||||
# 3. 신한라이프 Glow Framework 통신 (MCI / EAI) 설정
|
||||
glow:
|
||||
communication: #Communication 설정
|
||||
common:
|
||||
env-type: D # 대내표준 헤더의 환경 타입정보 (D: 개발, T: 테스트, P: 운영)
|
||||
http: # HTTP 비표준 통신 설정
|
||||
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위)
|
||||
read-timeout: 5 # 읽기 타임아웃 시간 (초 단위)
|
||||
mci: # 대내MCI 통신 설정
|
||||
host: http://host.docker.internal # 대내MCI 호스트 주소
|
||||
port: 8080 # 대내MCI 포트 번호
|
||||
uri: /mciSend # 대내MCI URI 정보
|
||||
receive-uri: /app/mciReceive # 대내MCI 수신 URI
|
||||
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위)
|
||||
read-timeout: 30 # 읽기 타임아웃 시간 (초 단위)
|
||||
encoding: UTF-8 # 대내MCI 인코딩 정보
|
||||
extmci: # 대외MCI 통신 설정
|
||||
host: http://host.docker.internal # 대외MCI 호스트 주소
|
||||
port: 8080 # 대외MCI 포트 번호
|
||||
uri: /extmci # 대외MCI URI 정보
|
||||
json-uri: /extmciJson # 대외MCI JSON방식 URI 정보
|
||||
receive-uri: /app/extMciReceive # 대외MCI 수신 URI
|
||||
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위)
|
||||
read-timeout: 30 # 읽기 타임아웃 시간 (초 단위)
|
||||
encoding: EUC-KR # 대외MCI 인코딩 정보
|
||||
eai: # EAI 통신 설정
|
||||
host: tcp://host.docker.internal # EAI 호스트 주소 (TODO: 실제 주소로 변경)
|
||||
port: 9999 # EAI 포트 번호
|
||||
websocket:
|
||||
endpoint: "/ws-glow"
|
||||
allowed-origins: "*"
|
||||
106
dap-tool-core/src/main/resources/mock-responses.json
Normal file
106
dap-tool-core/src/main/resources/mock-responses.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"BILL_001": {
|
||||
"status": "SUCCESS",
|
||||
"message": "청구 심사 상태 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"billingStatus": "PROCESSING",
|
||||
"expectedCompletionDate": "2026-07-05"
|
||||
}
|
||||
},
|
||||
"BILL_002": {
|
||||
"status": "SUCCESS",
|
||||
"message": "청구 처리가 완료되었습니다.",
|
||||
"data": {
|
||||
"processId": "PRC-998811",
|
||||
"result": "APPROVED"
|
||||
}
|
||||
},
|
||||
"BOND_001": {
|
||||
"status": "SUCCESS",
|
||||
"message": "디지털 증권 발행 가능 한도 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"availableLimit": 500000000,
|
||||
"currency": "KRW"
|
||||
}
|
||||
},
|
||||
"BOND_002": {
|
||||
"status": "SUCCESS",
|
||||
"message": "디지털 증권 발행이 성공적으로 완료되었습니다.",
|
||||
"data": {
|
||||
"bondId": "BND-2026-07-04-1234",
|
||||
"issuedAmount": 10000000
|
||||
}
|
||||
},
|
||||
"HR_VAC_01": {
|
||||
"status": "SUCCESS",
|
||||
"message": "연차 휴가 등록이 완료되었습니다.",
|
||||
"data": {
|
||||
"vacationId": "VAC-8877",
|
||||
"status": "APPROVED"
|
||||
}
|
||||
},
|
||||
"HR_VAC_02": {
|
||||
"status": "SUCCESS",
|
||||
"message": "잔여 연차 일수 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"totalDays": 15,
|
||||
"usedDays": 3,
|
||||
"remainingDays": 12
|
||||
}
|
||||
},
|
||||
"COM_SMS_01": {
|
||||
"status": "SUCCESS",
|
||||
"message": "SMS 발송이 성공적으로 완료되었습니다.",
|
||||
"data": {
|
||||
"messageId": "SMS-11223344",
|
||||
"sentTime": "2026-07-04 10:00:00"
|
||||
}
|
||||
},
|
||||
"COM_EML_01": {
|
||||
"status": "SUCCESS",
|
||||
"message": "이메일 발송이 성공적으로 완료되었습니다.",
|
||||
"data": {
|
||||
"emailId": "EML-998877",
|
||||
"sentTime": "2026-07-04 10:00:00"
|
||||
}
|
||||
},
|
||||
"CNTR_001": {
|
||||
"status": "SUCCESS",
|
||||
"message": "계약 상태 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"contractStatus": "ACTIVE",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
},
|
||||
"CNTR_002": {
|
||||
"status": "SUCCESS",
|
||||
"message": "계약 상세 내역 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"productName": "신한 라이프 스마트 연금보험",
|
||||
"monthlyPremium": 500000
|
||||
}
|
||||
},
|
||||
"CRM_001": {
|
||||
"status": "SUCCESS",
|
||||
"message": "고객 등급 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"customerGrade": "VIP",
|
||||
"loyaltyPoints": 15000
|
||||
}
|
||||
},
|
||||
"CRM_002": {
|
||||
"status": "SUCCESS",
|
||||
"message": "고객 상세 정보 조회가 완료되었습니다.",
|
||||
"data": {
|
||||
"address": "서울특별시 중구 세종대로 9",
|
||||
"phoneNumber": "010-XXXX-XXXX"
|
||||
}
|
||||
},
|
||||
"PAY_001": {
|
||||
"status": "SUCCESS",
|
||||
"message": "결제가 성공적으로 승인되었습니다.",
|
||||
"data": {
|
||||
"processStatus": "COMPLETED"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.shinhanlife.dap.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.common.integration.mci.dto.MciRequestWrapper;
|
||||
import io.shinhanlife.dap.common.integration.mci.dto.ShinhanCommonHeaderDto;
|
||||
import lombok.Data;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.adapter.sender
|
||||
* @className ShinhanMciSenderTest
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
class ShinhanMciSenderTest {
|
||||
|
||||
@Data
|
||||
static class SampleBody {
|
||||
private String msgCd;
|
||||
private String anxMsgCt;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJsonUnwrapped() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
ShinhanCommonHeaderDto header = new ShinhanCommonHeaderDto();
|
||||
header.setGlbId("20211115150135679009808815NCS17007887");
|
||||
header.setPgrsSriaNo("002");
|
||||
header.setItrIfId("NCSBACO00001");
|
||||
|
||||
SampleBody body = new SampleBody();
|
||||
body.setMsgCd("12345");
|
||||
body.setAnxMsgCt("Test Message");
|
||||
|
||||
MciRequestWrapper<SampleBody> wrapper = new MciRequestWrapper<>();
|
||||
wrapper.setTgrmCmnnhddValu(header);
|
||||
wrapper.setBody(body);
|
||||
|
||||
String json = mapper.writeValueAsString(wrapper);
|
||||
|
||||
System.out.println(json);
|
||||
|
||||
// 검증: body 필드가 json root 레벨에 평탄화되어 있는지 확인
|
||||
assertThat(json).contains("\"tgrmCmnnhddValu\":{");
|
||||
assertThat(json).contains("\"msgCd\":\"12345\"");
|
||||
assertThat(json).contains("\"anxMsgCt\":\"Test Message\"");
|
||||
assertThat(json).doesNotContain("\"body\":");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package io.shinhanlife.dap.biz.mcp.sample.converter;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleAiReqDto;
|
||||
import io.shinhanlife.dap.biz.mcp.sample.dto.SampleLegacyReqDto;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
public class SampleLegacyConverterTest {
|
||||
|
||||
// MapStruct가 자동 생성한 구현체를 가져와서 테스트합니다. (IDE 에러 방지를 위해 INSTANCE 참조)
|
||||
private final SampleLegacyConverter sampleLegacyConverter = SampleLegacyConverter.INSTANCE;
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 파라미터 DTO가 MCI DTO로 정확히 매핑되는지 테스트")
|
||||
public void testAiToMciMapping() {
|
||||
// given: AI Agent가 준 깔끔한 형태의 파라미터 생성
|
||||
SampleAiReqDto aiDto = SampleAiReqDto.builder()
|
||||
.userId("HONG_GILDONG")
|
||||
.actionType("SOATM0100R")
|
||||
.extraInfo("휴가신청내역 조회")
|
||||
.build();
|
||||
|
||||
// when: MapStruct 자동 생성 매퍼를 통해 1줄로 변환
|
||||
SampleLegacyReqDto mciDto = sampleLegacyConverter.toLegacyReq(aiDto);
|
||||
|
||||
// then: 콘솔에 결과 출력 및 값 검증
|
||||
System.out.println("====== MapStruct 변환 테스트 결과 ======");
|
||||
System.out.println(" [변환 전] AI DTO : " + aiDto);
|
||||
System.out.println(" [변환 후] Legacy DTO: " + mciDto);
|
||||
System.out.println("===============================================");
|
||||
|
||||
assertNotNull(mciDto, "변환된 객체는 null이 아니어야 합니다.");
|
||||
assertEquals("HONG_GILDONG", mciDto.getCustomerId(), "userId -> customerId 매핑 성공");
|
||||
assertEquals("SOATM0100R", mciDto.getInterfaceId(), "actionType -> interfaceId 매핑 성공");
|
||||
assertEquals("휴가신청내역 조회", mciDto.getRequestDetails(), "extraInfo -> requestDetails 매핑 성공");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user