refactor: dap-tool-* 모듈을 dap-was-* 이름으로 전면 개편 및 컨테이너명/파이프라인 연동
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m57s

This commit is contained in:
jade
2026-08-04 23:33:06 +09:00
parent 3e69436bfe
commit 136ad2fa71
268 changed files with 42 additions and 42 deletions

View File

@@ -0,0 +1,63 @@
package io.shinhanlife.dap.lib.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.lib.adapter.aop
* @className EimsMonitoringAspect
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Aspect
@Component
public class EimsMonitoringAspect {
// 포인트컷: io.shinhanlife.dap.lib.adapter.sender 패키지 내의 EimsSender를 구현한 모든 클래스의 메서드를 타겟으로 지정합니다.
@Around("execution(* io.shinhanlife.dap.lib.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;
}
}
}

View File

@@ -0,0 +1,72 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.dap.lib.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.lib.adapter.connector
* @className ExternalApiConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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());
}
}

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.dap.lib.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.lib.adapter.connector
* @className InternalSystemConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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());
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.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.lib.adapter.connector
* @className LegacyDbConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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 + ")");
}
}

View File

@@ -0,0 +1,151 @@
package io.shinhanlife.dap.lib.adapter.connector;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.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.lib.adapter.util.LegacyDataTransformer;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className LegacyEimsConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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")))
);
}
}

View File

@@ -0,0 +1,71 @@
package io.shinhanlife.dap.lib.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.lib.adapter.connector
* @className ThirdPartySecurityConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className ErrorDetail
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@AllArgsConstructor
@Builder
@NoArgsConstructor
public class ErrorDetail {
private int code;
private String message;
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className JsonRpcRequest
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
public class JsonRpcRequest {
private String jsonrpc;
private String method;
private Params params;
private String id;
}

View File

@@ -0,0 +1,30 @@
package io.shinhanlife.dap.lib.adapter.dto;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.Getter;
import lombok.Setter;
// 2. 응답 DTO
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className JsonRpcResponse
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@JsonPropertyOrder({"jsonrpc", "result", "error", "id"})
public class JsonRpcResponse {
public String jsonrpc = "2.0";
public Object result;
public Object error;
public String id;
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className Params
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Params {
private String routingType;
private String name;
private String interfaceId;
private Map<String, Object> data;
private List<Map<String, Object>> spec;
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.lib.adapter.exception;
/**
* @package io.shinhanlife.dap.lib.adapter.exception
* @className MciCommunicationException
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class MciCommunicationException extends RuntimeException {
public MciCommunicationException(String message) {
super(message);
}
public MciCommunicationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,104 @@
package io.shinhanlife.dap.lib.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.lib.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.lib.adapter.sender
* @className EaiEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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
);
}
}

View File

@@ -0,0 +1,20 @@
package io.shinhanlife.dap.lib.adapter.sender;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className EimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public interface EimsSender {
// 프로토콜에 상관없이 이 메서드 하나로 통일합니다.
String send(String interfaceId, String payload) throws Exception;
}

View File

@@ -0,0 +1,81 @@
package io.shinhanlife.dap.lib.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.lib.config.GlowCommunicationProperties;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className HttpEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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으로 받음
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className JspFormEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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() : "";
}
}

View File

@@ -0,0 +1,59 @@
package io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className JspJsonEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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() : "";
}
}

View File

@@ -0,0 +1,86 @@
package io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className MciEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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);
}
}

View File

@@ -0,0 +1,68 @@
package io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className MciStringEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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());
}
}
}

View File

@@ -0,0 +1,109 @@
package io.shinhanlife.dap.lib.adapter.sender;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.integration.mci.config.ShinhanIntegrationProperties;
import io.shinhanlife.dap.lib.integration.mci.dto.MciRequestWrapper;
import io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className ShinhanMciSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,71 @@
package io.shinhanlife.dap.lib.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.lib.adapter.sender
* @className TcpEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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");
}
}
}

View File

@@ -0,0 +1,59 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className DynamicPayloadBuilder
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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();
}
}

View File

@@ -0,0 +1,47 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className DynamicSchemaValidator
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,67 @@
package io.shinhanlife.dap.lib.adapter.support;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.adapter.exception.MciCommunicationException;
import io.shinhanlife.dap.lib.adapter.sender.EimsSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className MciTemplate
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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);
}
}
}

View File

@@ -0,0 +1,53 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className ResultStandardizer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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();
}
}

View File

@@ -0,0 +1,96 @@
package io.shinhanlife.dap.lib.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.lib.adapter.support
* @className TicketManager
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.dap.lib.adapter.support;
import io.shinhanlife.dap.lib.adapter.connector.LegacyEimsConnector;
import io.shinhanlife.dap.lib.adapter.dto.ErrorDetail;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcRequest;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.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.lib.adapter.support
* @className ToolExecutionService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,85 @@
package io.shinhanlife.dap.lib.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.lib.adapter.test
* @className MockEimsHttpServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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);
// MciSampleStringResponse 에 맞게 고정 길이 응답 생성
// name (10), age (3), joinDate (8), statusCode (2)
// targetList (30) -> MciSampleTargetDto (itemCode 5, itemValue 5) x 3
return "홍길동 03020260901OKA0001B0001A0002B0002A0003B0003";
}
}

View File

@@ -0,0 +1,128 @@
package io.shinhanlife.dap.lib.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.lib.adapter.test
* @className MockEimsTcpServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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) {}
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.dap.lib.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.lib.adapter.test
* @className MockJspServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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";
}
}

View File

@@ -0,0 +1,92 @@
package io.shinhanlife.dap.lib.adapter.util;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.util
* @className LegacyDataTransformer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,35 @@
package io.shinhanlife.dap.lib.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.lib.adapter.util
* @className PiiMaskingLogbackConverter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class PiiMaskingLogbackConverter extends MessageConverter {
@Override
public String convert(ILoggingEvent event) {
// 원본 로그 메시지를 가져옵니다.
String originalMessage = super.convert(event);
// 정규식을 이용하여 개인정보가 포함되어 있으면 마스킹 처리하여 반환합니다.
return PiiMaskingUtils.mask(originalMessage);
}
}

View File

@@ -0,0 +1,77 @@
package io.shinhanlife.dap.lib.adapter.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @package io.shinhanlife.dap.lib.adapter.util
* @className PiiMaskingUtils
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* MCP 스키마 생성 시 anyOf (해당 필드들 중 최소 1개 이상 필수) 제약을 부여합니다.
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpAnyOf {
/**
* anyOf 제약에 포함될 필드명 목록
* 예: @McpAnyOf({"claimNo", "contractNo"})
*/
String[] value();
}

View File

@@ -0,0 +1,76 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpFunction
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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 "";
/**
* Tool 입력 JSON Schema를 인라인으로 지정한다. 지정하지 않으면 요청 DTO에서 자동 생성한다.
*/
String inputSchema() default "{}";
/**
* 복합 조건(anyOf 등)이 필요한 Tool의 입력 JSON Schema 클래스패스 경로다.
* inputSchemaResource가 지정되면 inputSchema 및 DTO 자동 생성보다 우선한다.
*/
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
String inputSchemaResource() default "";
/**
* Tool response JSON Schema. When unset, output validation is skipped.
*/
String outputSchema() default "{}";
/**
* Classpath resource for a complex Tool response JSON Schema.
* This value has priority over outputSchema.
*/
String outputSchemaResource() default "";
boolean register() default false;
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
boolean visible() default true;
// 추가: HITL 승인 체계 지원 (실행 전 사용자 승인 필요 여부)
boolean requiresApproval() default false;
boolean readOnlyHint() default false;
boolean destructiveHint() default false;
boolean idempotentHint() default false;
boolean openWorldHint() default false;
/** Version exposed as _meta.version in the Tool Manifest. */
String version() default "1.0.0";
/** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */
long timeoutMillis() default 300000L;
/** Whether the Tool is available for MCP exposure. */
boolean enabled() default true;
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
}

View File

@@ -0,0 +1,17 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a Tool response DTO for automatic output JSON Schema generation.
* Field constraints are declared with {@link McpValidation}.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpOutputSchema {
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpParameter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpParameter {
String description();
boolean required() default false;
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpTool
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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";
}

View File

@@ -0,0 +1,36 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpValidation
* @description Declares JSON Schema validation constraints for MCP tool input fields
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpValidation {
boolean required() default false;
String pattern() default "";
long minimum() default Long.MIN_VALUE;
long maximum() default Long.MAX_VALUE;
int minLength() default -1;
int maxLength() default -1;
String[] allowedValues() default {};
String format() default "";
boolean nullable() default false; String defaultValue() default "";
String[] examples() default {};
}

View File

@@ -0,0 +1,83 @@
package io.shinhanlife.dap.lib.aop;
/**
* @package io.shinhanlife.dap.lib.aop
* @className ToolSlaMonitoringAspect
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.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;
}
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.lib.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @package io.shinhanlife.dap.lib.config
* @className CorsConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 엔드포인트에 대해 CORS 허용
.allowedOriginPatterns("*") // 외부 Agent Builder 등 모든 오리진 허용
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.exposedHeaders("Mcp-Session-Id") // MCP-HTTP 세션 아이디 노출 허용
.allowCredentials(true) // 쿠키/인증 정보 허용
.maxAge(3600); // preflight 요청 캐시 시간 (초 단위)
}
}

View File

@@ -0,0 +1,85 @@
package io.shinhanlife.dap.lib.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.lib.config
* @className GlowCommunicationProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </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;
}
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.lib.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.lib.config
* @className McpProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "mcp")
public class McpProperties {
private String namespace;
private Manifest manifest = new Manifest();
@Data
public static class Manifest {
private String bundleId;
private String namePrefix;
}
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.lib.config;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.config
* @className MybatisConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class MybatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
return sessionFactory.getObject();
}
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.lib.config;
import com.p6spy.engine.logging.Category;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
/**
* @package io.shinhanlife.dap.lib.config
* @className P6SpySqlFormatter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class P6SpySqlFormatter implements MessageFormattingStrategy {
@Override
public String formatMessage(int connectionId, String now, long elapsed,
String category, String prepared, String sql, String url) {
if (sql == null || sql.isBlank()) return "";
if (Category.STATEMENT.getName().equals(category)) {
String prettySQL = sql
.replaceAll("(?i)\\bSELECT\\b", "\nSELECT")
.replaceAll("(?i)\\bFROM\\b", "\n FROM")
.replaceAll("(?i)\\bWHERE\\b", "\n WHERE")
.replaceAll("(?i)\\bAND\\b", "\n AND")
.replaceAll("(?i)\\bOR\\b", "\n OR")
.replaceAll("(?i)\\bINNER JOIN\\b", "\n INNER JOIN")
.replaceAll("(?i)\\bLEFT JOIN\\b", "\n LEFT JOIN")
.replaceAll("(?i)\\bORDER BY\\b", "\n ORDER BY")
.replaceAll("(?i)\\bGROUP BY\\b", "\n GROUP BY")
.replaceAll("(?i)\\bINSERT INTO\\b", "\nINSERT INTO")
.replaceAll("(?i)\\bVALUES\\b", "\n VALUES")
.replaceAll("(?i)\\bUPDATE\\b", "\nUPDATE")
.replaceAll("(?i)\\bSET\\b", "\n SET")
.replaceAll("(?i)\\bDELETE FROM\\b", "\nDELETE FROM");
return String.format("""
\n┌─────────────────────────────────────────
│ SQL [%dms]
│%s
└─────────────────────────────────────────
""", elapsed, prettySQL.indent(2).stripTrailing());
}
return "";
}
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Common MCP Tool Schema Bean configuration.
*/
@Configuration
public class ToolSchemaConfiguration {
@Bean
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
return new ToolSchemaResolver(objectMapper);
}
}

View File

@@ -0,0 +1,20 @@
package io.shinhanlife.dap.lib.dto;
/**
* @package io.shinhanlife.dap.mcg.dto
* @className OperationType
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public enum OperationType {
READ,
WRITE
}

View File

@@ -0,0 +1,132 @@
package io.shinhanlife.dap.lib.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.HashSet;
/**
* Tool(Agent)의 명세 및 라우팅 정보를 담고 있는 메타데이터 클래스
* Redis 레지스트리에 저장되며, Planner와 Router 간의 통신 객체(Plan)로 사용됩니다.
*/
/**
* @package io.shinhanlife.dap.mcg.dto
* @className ToolMetadata
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
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;
// 2-2. 툴 처리 엔드포인트 URI 경로 (예: /api/tool/customer-info)
private String endpoint;
// 2-3. Pod 실행 URL (독립적인 Microservice 라우팅용, 예: http://localhost:8082)
private String podUrl;
// 2-4. 가시성 여부
@Builder.Default
private Boolean visible = true;
// 활성화 여부
@Builder.Default
private Boolean enabled = true;
// 2-5. Redis 등록 여부 (UI 표출용)
@Builder.Default
private Boolean isRegistered = true;
// 2-6. HITL 승인 필요 여부
@Builder.Default
private Boolean requiresApproval = false;
@Builder.Default
private Boolean readOnlyHint = false;
@Builder.Default
private Boolean destructiveHint = false;
@Builder.Default
private Boolean idempotentHint = false;
@Builder.Default
private Boolean openWorldHint = false;
// 3. 연동 아키텍처 구분 (DIRECT / MCI_EAI)
private String integrationType; // 연동 타입: "DIRECT" 또는 "MCI_EAI"
// 4. 레거시(MCI/EAI) 연동 시 필수 정보 (integrationType이 "MCI_EAI"일 때 사용)
private String mciServiceId; // MCI/EAI 호출을 위한 서비스 ID (예: CRM_001, LICO_992)
// 5. 인프라 상태 정보 (DIRECT 연동 시 사용)
private Long lastHeartbeat; // Redis TTL 갱신용 마지막 하트비트 타임스탬프
// 6. 동적 서킷 브레이커 & 속도 제어 설정 (Registry 기반)
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수
// 7. Gateway 코어 제어용 설정 필드 추가 (재시도, 타임아웃, 오퍼레이션 타입)
@Builder.Default
private OperationType operationType = OperationType.READ;
@Builder.Default
private Boolean retryEnabled = true;
@Builder.Default
private Integer circuitBreakerFailureThreshold = 0;
@Builder.Default
private Long circuitBreakerOpenMillis = 0L;
@Builder.Default
private Long timeoutMillis = 0L;
// --- Guardrail 호환성을 위한 메서드 추가 ---
public Set<String> allowedArguments() {
if (parametersSchema == null || !parametersSchema.containsKey("properties")) return Set.of();
return ((Map<String, Object>) parametersSchema.get("properties")).keySet();
}
public Set<String> requiredArguments() {
if (parametersSchema == null || !parametersSchema.containsKey("required")) return Set.of();
return new HashSet<>((List<String>) parametersSchema.get("required"));
}
}

View File

@@ -0,0 +1,105 @@
package io.shinhanlife.dap.lib.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
// TODO: 실제 Glow Framework 의존성이 추가되면 아래 주석들을 풀고 사용하세요!
// import io.shinhanlife.glow.communication.dto.CommonHeader;
// import io.shinhanlife.glow.communication.dto.Transfer;
// import io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent;
// import io.shinhanlife.glow.communication.module.mci.component.GlowExtMciComponent;
// import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
// import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
/**
* [MCI / EAI 공통 연동 래퍼(Wrapper) 템플릿]
* 신한라이프 Glow Framework 개발표준정의서를 바탕으로 대내/대외망/EAI 통신을
* MCP 툴에서 손쉽게 호출할 수 있도록 일원화한 컴포넌트입니다.
*/
/**
* @package io.shinhanlife.dap.lib.integration
* @className GlowIntegrationCall
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Component
@RequiredArgsConstructor
public class GlowIntegrationCall {
// 1. 실제 의존성이 주입될 프레임워크 컴포넌트들 (임시 주석 처리)
/*
private final GlowMciComponent mci;
private final GlowEaiComponent eai;
private final GlowExtMciComponent extMci;
*/
/**
* 1. 대외 MCI 호출 (타행, 금융결제원 등 외부 기관)
* 가이드 2.2.2에 명시된 필수 파라미터(기관코드, 종별코드, 업무코드, 거래코드)를 모두 포함합니다.
*/
/*
public <S, R> Transfer<R> callExtMci(String itrfId, String frbuCd, String cmouDutjCd, String cmouCssfCd, String cmouTraCd, S body, Class<R> resBody) {
// 1. 공통 헤더 생성
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
// 2. 대외 전용 필수 코드 세팅 로직 (프레임워크 내부 스펙에 맞게 가공)
// (예: 헤더에 해당 속성들을 주입하거나 Transfer 객체에 싣는 과정 추가)
// 3. Transfer 객체 빌드
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
// 4. 대외 MCI 컴포넌트를 통해 최종 전송
return extMci.call(req, resBody);
}
*/
/**
* 2. EAI 호출 (대내망 중계기)
* 가이드 2.3에 명시된 대로 수신서비스 ID 없이 인터페이스 ID(itrfId)만 필수로 받습니다.
*/
/*
public <S, R> Transfer<R> callEai(String itrfId, S body, Class<R> resBody) {
// 1. EAI는 인터페이스 ID만으로 심플하게 헤더 생성
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
// 2. Transfer 객체 빌드
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
// 3. EAI 컴포넌트를 통해 최종 전송
return eai.call(req, resBody);
}
*/
/**
* 3. 대내 MCI 호출 (사내 시스템 간 통신)
*/
/*
public <S, R> Transfer<R> callMci(String itrfId, S body, Class<R> resBody) {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
return mci.call(req, resBody);
}
*/
}

View File

@@ -0,0 +1,84 @@
package io.shinhanlife.dap.lib.integration.dto;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
// TODO: 실제 Glow Framework 의존성이 추가되면 아래 주석을 풀고 사용하세요!
// import io.shinhanlife.glow.communication.annotation.GlowMciFieldInfo;
/**
* [대외 MCI 연동용 DTO 표준 템플릿]
* Glow Framework 개발표준정의서(2.2.1 IO 작성) 규칙을 100% 준수한 샘플입니다.
* 새로운 대외 통신 전문을 만들 때 이 파일을 복사해서 필드명과 길이만 수정하여 사용하세요.
*/
/**
* @package io.shinhanlife.dap.lib.integration.dto
* @className SampleGlowMessage
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC) // [규칙 1] Reflection을 위한 기본 생성자 필수 (public 유지)
public class SampleGlowMessage {
// [규칙 2] @GlowMciFieldInfo 선언 필수 (order: 순서)
// @GlowMciFieldInfo(order = 1)
private MessageHeader header;
// [규칙 2] @GlowMciFieldInfo 선언 필수 (order: 순서)
// @GlowMciFieldInfo(order = 2)
private List<MessageBody> msgDtdvValu; // 다건(List) 본문 데이터
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public static class MessageHeader {
// [규칙 2] 단건 필드의 경우 length 필수 입력 (EIMS 길이와 일치해야 함)
// @GlowMciFieldInfo(order = 1, length = 1)
private String msgTnsmTypeCd;
// @GlowMciFieldInfo(order = 2, length = 8)
private int msdvLen;
// [규칙 3] 다건(List) 건수 필드의 경우, target 속성에 대상 변수명("msgDtdvValu") 필수 기입!
// @GlowMciFieldInfo(order = 3, length = 2, target = "msgDtdvValu")
private int msgRpttCc;
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public static class MessageBody {
// @GlowMciFieldInfo(order = 1, length = 8)
private String msgCd;
// @GlowMciFieldInfo(order = 2, length = 1)
private String msgPrnAttrCd;
// @GlowMciFieldInfo(order = 3, length = 200)
private String msgCt;
// @GlowMciFieldInfo(order = 4, length = 200)
private String anxMsgCt;
}
}

View File

@@ -0,0 +1,99 @@
package io.shinhanlife.dap.lib.integration.eai.component;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.dto.CommonHeader;
import io.shinhanlife.glow.communication.dto.Transfer;
import io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent;
import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 신한라이프 내부 Glow 표준 EAI 컴포넌트 어댑터 (AXHUB)
*/
@Slf4j
@Component
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.eai.component
* @className AxhubEaiComponent
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class AxhubEaiComponent {
@SuppressWarnings("rawtypes")
private final GlowEaiComponent eai;
private final GlowCommunicationProperties communicationProperties;
@SuppressWarnings("unchecked")
private <O> Transfer<O> syncEai(Transfer<Object> request) {
// LOG 저장 (AXHUB 방식 로깅)
CommonHeader reqHeader = (CommonHeader) request.getHeader();
log.info("[AxhubEaiComponent] {} EAI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
Transfer<O> response = (Transfer<O>) eai.sync(request);
log.info("[AxhubEaiComponent] {} EAI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
return response;
}
/**
* EAI 호출
*/
public <O, I> Transfer<O> call(String itrfId, String rcvSvcId, I inputDto) throws Exception {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId, rcvSvcId);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.build();
return syncEai(request);
}
/**
* EAI 호출 (응답 타입 명시)
*/
@SuppressWarnings("unchecked")
public <O, I> Transfer<O> call(String itrfId, String rcvSvcId, I inputDto, Class<O> resBodyClass) throws Exception {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId, rcvSvcId);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.resBodyClass((Class<Object>) (Class<?>) resBodyClass)
.build();
return syncEai(request);
}
/**
* EAI 호출 (rcvSvcId 없는 경우)
*/
public <O, I> Transfer<O> call(String itrfId, I inputDTO, Class<O> resBodyClass) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return call(itrfId, rcvSvcId, inputDTO, resBodyClass);
}
/**
* EAI 호출 (Response body class와 rcvSvcId 없는 경우)
*/
public <O, I> Transfer<O> call(String itrfId, I inputDTO) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return call(itrfId, rcvSvcId, inputDTO);
}
}

View File

@@ -0,0 +1,168 @@
package io.shinhanlife.dap.lib.integration.mci.component;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.util.SessionUtil;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.dto.CommonHeader;
import io.shinhanlife.glow.communication.dto.HeaderDefaults;
import io.shinhanlife.glow.communication.dto.Transfer;
import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
import io.shinhanlife.dap.lib.integration.mci.enums.IndvCtinRoleTyp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 신한라이프 내부 Glow 표준 컴포넌트 어댑터 (AXHUB)
*/
@Slf4j
@Component
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.mci.component
* @className AxhubMciComponent
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class AxhubMciComponent {
@SuppressWarnings("rawtypes")
private final GlowMciComponent mci;
private final GlowCommunicationProperties communicationProperties;
/** 전문생성채널유형코드 : 1 (채널계) */
private final static String TGRM_CREA_CHNN_TYPE_CD_1 = "1";
private static final String SUCO_UNBL_CODE = "NNB00147"; // 청약불가
/**
* 전문 Common Header 생성
* @param itrfName 인터페이스Id
* @param rcvSvcId 수신서비스Id
* @return Map<HeaderDefaults, String>
*/
private Map<HeaderDefaults, String> createCommonHeaderMap(String itrfName, String rcvSvcId) {
SessionDto sessionDto = SessionUtil.getSession();
Map<HeaderDefaults, String> commonHeaderMap = new HashMap<>();
commonHeaderMap.put(HeaderDefaults.ITRF_ID, itrfName);
commonHeaderMap.put(HeaderDefaults.RCV_SVC_ID, rcvSvcId);
if (sessionDto != null) {
commonHeaderMap.put(HeaderDefaults.STR_YMD, sessionDto.getStrYmd());
commonHeaderMap.put(HeaderDefaults.ACNT_OGNZ_NO, sessionDto.getBrafNo());
commonHeaderMap.put(HeaderDefaults.PSMR_ASRT_CD, sessionDto.getPsmrAsrtCd());
commonHeaderMap.put(HeaderDefaults.SBSN_RULP_ASRT_CD, sessionDto.getSbsnRulpAsrtCd());
commonHeaderMap.put(HeaderDefaults.BSDU_CD, sessionDto.getBsduCd());
commonHeaderMap.put(HeaderDefaults.BSQU_CD, sessionDto.getBsquCd());
commonHeaderMap.put(HeaderDefaults.OGNZ_ASRT_CD, sessionDto.getOgnzAsrtCd());
commonHeaderMap.put(HeaderDefaults.OGNZ_LEVE_CD, sessionDto.getOgnzLeveCd());
commonHeaderMap.put(HeaderDefaults.SCRN_ID, sessionDto.getPrgrId());
}
commonHeaderMap.put(HeaderDefaults.INDV_CTIN_ROLE_CD, IndvCtinRoleTyp.CD_Z99.getCode());
commonHeaderMap.put(HeaderDefaults.TGRM_CREA_CHNN_TYPE_CD, TGRM_CREA_CHNN_TYPE_CD_1);
if (communicationProperties != null && communicationProperties.getCommon() != null) {
commonHeaderMap.put(HeaderDefaults.ENVR_TYPE_CD, communicationProperties.getCommon().getEnvType());
}
return commonHeaderMap;
}
@SuppressWarnings("unchecked")
private <O> Transfer<O> syncMci(Transfer<Object> request) {
// LOG 저장 (AXHUB 방식 로깅)
CommonHeader reqHeader = (CommonHeader) request.getHeader();
log.info("[AxhubMciComponent] {} MCI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
Transfer<O> response = (Transfer<O>) mci.sync(request);
log.info("[AxhubMciComponent] {} MCI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
if (response != null && response.getHeader() != null) {
CommonHeader resHeader = (CommonHeader) response.getHeader();
String tgrmDalRsltCd = resHeader.getTgrmDalRsltCd();
// TODO 추가 메시지 처리 및 오류 코드 제어 로직
}
return response;
}
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto) throws Exception {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.build();
return syncMci(request);
}
/**
* 대내 mci 호출
* @param itrfName 인터페이스Id
* @param rcvSvcId 수신서비스Id
* @param inputDto inputDto
* @param resBodyClass resBodyClass
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
*/
@SuppressWarnings("unchecked")
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto, Class<O> resBodyClass) throws Exception {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.resBodyClass((Class<Object>) (Class<?>) resBodyClass)
.build();
return syncMci(request);
}
/**
* 대내 mci 호출 (rcvSvcId 없는 경우)
* @param itrfName 인터페이스Id
* @param inputDTO 수신서비스Id (클래스명 대체)
* @param resBodyClass resBodyClass
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO, Class<O> resBodyClass) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO, resBodyClass);
}
/**
* 대내 mci 호출 (Response body class와 rcvSvcId 없는 경우)
* @param itrfName 인터페이스Id
* @param inputDTO 수신서비스Id (클래스명 대체)
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO);
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.integration.mci.config;
import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* TODO: 실제 Glow Framework 의존성이 추가되어 io.shinhanlife.glow 패키지가
* ComponentScan에 잡히게 되면 이 설정 클래스는 삭제하세요.
*/
@Configuration
/**
* @package io.shinhanlife.dap.lib.integration.mci.config
* @className GlowMockConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class GlowMockConfig {
@Bean
@SuppressWarnings("rawtypes")
public GlowMciComponent glowMciComponent() {
return new GlowMciComponent();
}
@Bean
@SuppressWarnings("rawtypes")
public io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent glowEaiComponent() {
return new io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent();
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.integration.mci.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.integration.mci.config
* @className ShinhanIntegrationProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "shinhan.integration")
public class ShinhanIntegrationProperties {
/**
* 환경유형코드: 운영(R), 테스트(T), 개발(D)
*/
private String envrTypeCd = "D";
private ServerInfo eai = new ServerInfo();
private ServerInfo internalMci = new ServerInfo();
@Getter
@Setter
public static class ServerInfo {
private String url;
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className MciRequestWrapper
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MciRequestWrapper<T> {
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
@JsonUnwrapped
private T body;
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className MciResponseWrapper
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MciResponseWrapper<T> {
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
@JsonUnwrapped
private T body;
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className OlCommonHeaderDto
* @description AX HUB 시스템 처리 클래스 - OL(구 오렌지라이프) 공통 헤더
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OlCommonHeaderDto {
private String custNm; // 고객명
private String custRrn; // 고객 주민등록번호
private String custNo; // 고객번호
private String rcevNo; // 접수번호
private String pono; // 증권번호
private String scrNm; // 화면명
private String scrId; // 화면ID
private String lginDttm; // 사용자가 로그인한 접속일시
private String lginIpAddr; // 사용자가 접속한 IP 주소
private String userNm; // 사용자 이름(한글)
private String userEngNm; // 사용자 영문이름
private String userId; // 사용자 ID(AD ID)
private String userNo; // 사용자번호
private String deptCd; // 사용자조직 코드
private String salsDvCd; // 영업본부코드
private String salsBoCd; // 영업지점코드
private String uppDeptCd; // 상위조직코드
private String prcsrUserId; // 처리자 ID(AD ID)
private String prcsrUserNo; // 처리지번호
private String prcsrDeptCd; // 처리지조직 코드
private String prcsrDvCd; // 처리지 영업본부코드
private String prcsrBoCd; // 처리지 영업지점코드
private String prcsrUppDeptCd; // 부서코드
private String sysCd; // 요청이 들어온 시스템을 표시
private String reqtSvcNm; // 요청하는 서비스 모듈명
private String reqtMthdNm; // 요청하는 메소드명
private String reqtVoNm; // 요청메소드에 전달할 값을 담는 VO명
private String scrButnFuncClssCd; // 화면에서 버튼 별 이벤트 구분을 위한 구분코드
private String scrGriCnt; // 화면 그리드 개수
private List<OlPageDto> pageList; // 페이징 리스트 (L2 반복)
private String reqtDttm; // 요청일시
private String crdtInfoIcluFlg; // 신용정보포함여부(Y,N)
private String crdtInfoDataChgTypCd; // 업무내역별 식별코드 부여
private String crdtInfoIdfInEngAbbrNm; // 신용정보식별영문약어명
private String crdtInfoIdfnSysCd; // 신용정보식별시스템코드
private String scrButnNm; // 화면버튼명
private String msgCnt; // 메시지 개수
private List<OlMsgDto> msgList; // 메시지 리스트 (L2 반복)
private String respDttm; // 응답일시
private String svcRunNm; // 거래별로 유일한 ServiceExecutionID
private String stdate; // 기준일자
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class OlPageDto {
private String pageSrno; // 페이지 인덱스값 (L3)
private String pageInqCnt; // 한페이지에 조회될 건수 (L3)
private String nxtButnNm; // 다음버튼ID (L3)
private String nxtButnEnbFlg; // 다음버튼 활성여부 (L3)
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class OlMsgDto {
private String msgNo; // 서버 측에서 세팅한 정상/에러 메시지코드 (L3)
private String msgTypCd; // 메시지유형코드 (L3)
private String msgNm; // 메시지코드의 내용 (L3)
}
}

View File

@@ -0,0 +1,72 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanCommonHeaderDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanCommonHeaderDto {
private String tgrmLencn; // 전문길이
private String glbId; // 글로벌ID (전사공통키)
private String pgrsSriaNo; // 진행일련번호
private String tgrmVrsnInfoValu; // 전문버전정보값
private String tgrmEncrYn; // 전문암호화여부
private String gpcpCd; // 그룹사코드
private String appliDutjCd; // 어플리케이션업무코드
private String appliDtptDutjCd; // 어플리케이션상세업무코드
private String frbuCd; // 대외기관코드
private String cmouDutjCd; // 대외업무코드
private String cmouCssfCd; // 대외종별코드
private String cmouTraCd; // 대외거래코드
private String rcvSvcId; // 수신서비스ID
private String rsltRcvSvcId; // 결과수신서비스ID
private String tgrmCreaChnnTypeCd; // 전문생성채널유형코드
private String lnggDvsnCd; // 언어구분코드
private String simulTraYn; // 시뮬레이션거래여부
private String itrIfId; // 인터페이스ID
private String reqRspnScCd; // 요청응답구분코드
private String tnsmTypeCd; // 전송유형코드
private String envrTypeCd; // 환경유형코드
private String inqrTraTypeCd; // 조회거래유형코드
private String reqTgrmTnsmDtptDt; // 요청전문전송상세일시
private String strYmd; // 기준일자
private String scrnId; // 화면ID
private String scrnBtnId; // 화면버튼ID
private String userIpAddr; // 사용자IP주소
private String drtmCd; // 부서코드
private String userId; // 사용자ID
private String indvCtinRoleCd; // 개인신용정보역할코드
private String acntOgnzNo; // 경리조직번호
private String rspnTgrmTnsmDtptDt; // 응답전문전송상세일시
private String tgrmDalRsltCd; // 전문처리결과코드
private String ognzAsrtCd; // 조직분류코드
private String ognzLeveCd; // 조직레벨코드
private String psmrAsrtCd; // 인사조직분류코드
private String sbsnRulpAsrtCd; // 영업규정분류코드
private String bsduCd; // 영업지국코드
private String bsquCd; // 영업자격코드
private String linkPrafDutyCd; // 연계인사직책코드
private String indvInfoLogWritYn; // 개인정보로그작성여부
private String prepImhdNm; // 예비항목명
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanMessageDto
* @description AX HUB 시스템 처리 클래스 - MCI 전문 메시지부
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanMessageDto {
private MsgHddvValu msgHddvValu; // 메시지헤더부값
private MsgDtdvValu msgDtdvValu; // 메시지데이터부값
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MsgHddvValu {
private String msgTnsmTypeCd; // 메시지전송유형코드
private Integer msdvLencn; // 메시지부길이
private Integer msgRpttCc; // 메시지반복건수
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MsgDtdvValu {
private String msgCd; // 메시지코드
private String msgPrnAttrCd; // 메시지출력속성코드
private String msgCt; // 메시지내용
private String anxMsgCt; // 부가메시지내용
}
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanTelegramWrapper
* @description AX HUB 시스템 처리 클래스 - MCI 전문 전체 래퍼 (공통헤더부 + 메시지부 + 데이터부)
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanTelegramWrapper<T> {
// 1. 공통 헤더부
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
// 2. 메시지부
private ShinhanMessageDto tgrmMsdvValu;
// 3. 데이터부 (비즈니스마다 다름, JsonUnwrapped로 평탄화하거나 객체 자체로 유지 가능. 여기서는 객체 유지)
private T tgrmDtdvValu;
}

View File

@@ -0,0 +1,84 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className SlCommonHeaderDto
* @description AX HUB 시스템 처리 클래스 - SL(신한라이프) 표준 헤더
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SlCommonHeaderDto {
private String length; // 전문길이
private SlGlobalId globalId; // 글로벌ID
private String headerVer; // 전문헤더버전
private String encodeFlag; // 전문암호화여부
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SlGlobalId {
private String writeDate; // 전문작성일 (8)
private String sysCd; // 생성시스템명 (8)
private String typeCd; // 구분코드 (2)
private String detailCd; // 세부업무코드 (4)
private String seqNo; // 채번번호 (8)
private String step; // 진행상황 (2)
}
private String groupCoCd; // 그룹사코드
private String instCd; // 기관코드
private String applCd; // 업무코드
private String kindCd; // 종별코드
private String txCd; // 거래코드
private String pfmAppName; // 어플리케이션 명
private String pfmSvcName; // 서비스 명
private String pfmFnName; // 오퍼레이션 명
private String systemCd; // 생성시스템구분
private String trFlag; // 요청응답구분
private String syncFlag; // 동기구분
private String envrFlag; // 환경구분
private String crudFlag; // 조회거래구분
private String sendTime; // 전문전송일시
private String screenId; // 화면ID
private String clntIp; // Client IP
private String orgCd; // 부서(지점)코드
private String userId; // 사용자 사번(아이디)
private String indvCrdtInfo; // 개인신용정보역할코드
private String acntOgnzNo; // 경리조직번호
private String ttiFlag; // TimeOut사용
private String ttiStartTm; // 최초시작시간
private String ttiKeepTm; // 유지시간초수
private String outMsgTm; // 응답전문작성일시
private String resType; // 처리결과
private String resCode; // 응답코드
private String resBascMsg; // 응답기본내역
private String msgType; // 메시지 유형
private String rcvSvcCd; // 수신 서비스 Code
private String rsltRcvSvcCd; // 결과수신 서비스 Code
private String realSvcCd; // Real 서비스 Code
private String ognzAsrtCd; // 조직분류코드
private String ognzLeveCd; // 조직레벨구분코드
private String psmrAsrtCd; // 인사조직분류코드
private String sbsnRulpAsrtCd; // 영업규정분류코드
private String bsduCd; // 영업지국코드
private String bsquCd; // 영업자격코드
private String linkPrafDutyCd; // 직책코드
private String temp; // 예비 필드
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.lib.integration.mci.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.mci.enums
* @className IndvCtinRoleTyp
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public enum IndvCtinRoleTyp {
CD_Z99("Z99");
private final String code;
}

View File

@@ -0,0 +1,10 @@
package io.shinhanlife.dap.lib.manifest;
/** Behaviour hints exposed by the Tool Service manifest. */
public record ToolManifestAnnotations(
String title,
boolean readOnlyHint,
boolean destructiveHint,
boolean idempotentHint,
boolean openWorldHint) {
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.lib.manifest;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
/** One MCP Tool declaration published by a Tool Service. */
public record ToolManifestItem(
String name,
String endpoint,
String title,
String description,
Map<String, Object> inputSchema,
ToolManifestAnnotations annotations,
@JsonProperty("_meta") ToolManifestMeta meta) {
}

View File

@@ -0,0 +1,5 @@
package io.shinhanlife.dap.lib.manifest;
/** Operational metadata exposed by the Tool Service manifest. */
public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.dap.lib.manifest;
import java.util.List;
/** Top-level response for GET /tool-manifest. */
public record ToolManifestResponse(String bundleId, String revision, List<ToolManifestItem> tools) {
}

View File

@@ -0,0 +1,131 @@
package io.shinhanlife.dap.lib.manifest;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.lib.usecase.ToolRegistryHeartbeatSender;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/** Builds the Tool Service owned manifest consumed by the MCP server. */
@Service
public class ToolManifestService {
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
private static final AtomicLong LAST_ISSUED_REVISION = new AtomicLong();
private final Supplier<List<ToolMetadata>> toolSupplier;
private final ObjectMapper objectMapper;
private final McpProperties properties;
private String lastFingerprint;
private String lastRevision;
@Autowired
public ToolManifestService(ToolRegistryHeartbeatSender heartbeatSender, ObjectMapper objectMapper,
McpProperties properties) {
this(heartbeatSender::getAllScannedTools, objectMapper, properties);
}
ToolManifestService(Supplier<List<ToolMetadata>> toolSupplier, ObjectMapper objectMapper,
McpProperties properties) {
this.toolSupplier = toolSupplier;
this.objectMapper = objectMapper;
this.properties = properties;
}
public ToolManifestResponse currentManifest() {
String bundleId = properties.getManifest() == null ? null : properties.getManifest().getBundleId();
if (bundleId == null || bundleId.isBlank()) {
throw new IllegalStateException("mcp.manifest.bundle-id must be configured");
}
List<ToolManifestItem> tools = toolSupplier.get().stream()
.map(this::toManifestItem)
.sorted(Comparator.comparing(ToolManifestItem::name))
.toList();
validate(tools);
return new ToolManifestResponse(bundleId, revision(bundleId, tools), tools);
}
private ToolManifestItem toManifestItem(ToolMetadata tool) {
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
? tool.getName() : tool.getDisplayName();
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
return new ToolManifestItem(
tool.getName(), endpoint(tool), title, tool.getDescription(), schema,
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
tool.getEnabled() == null || tool.getEnabled()));
}
private void validate(List<ToolManifestItem> tools) {
String namePrefix = properties.getManifest() == null ? null : properties.getManifest().getNamePrefix();
Set<String> names = new LinkedHashSet<>();
for (ToolManifestItem tool : tools) {
if (tool.name() == null || tool.name().isBlank()) {
throw new IllegalStateException("Tool manifest contains a blank tool name");
}
if (!names.add(tool.name())) {
throw new IllegalStateException("Tool manifest contains duplicate tool name: " + tool.name());
}
if (namePrefix != null && !namePrefix.isBlank() && !tool.name().startsWith(namePrefix)) {
throw new IllegalStateException("Tool name does not match mcp.manifest.name-prefix: " + tool.name());
}
if (!"object".equals(tool.inputSchema().get("type"))) {
throw new IllegalStateException("Tool inputSchema root type must be object: " + tool.name());
}
}
}
private synchronized String revision(String bundleId, List<ToolManifestItem> tools) {
String fingerprint = fingerprint(bundleId, tools);
if (!fingerprint.equals(lastFingerprint)) {
long localMinimum = lastRevision == null ? Long.MIN_VALUE : Long.parseLong(lastRevision) + 1;
long nextTimestamp = LAST_ISSUED_REVISION.updateAndGet(previous ->
Math.max(Math.max(System.currentTimeMillis(), localMinimum), previous + 1));
lastFingerprint = fingerprint;
lastRevision = Long.toString(nextTimestamp);
}
return lastRevision;
}
private String fingerprint(String bundleId, List<ToolManifestItem> tools) {
try {
return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools));
} catch (Exception exception) {
throw new IllegalStateException("Failed to build Tool manifest revision source", exception);
}
}
private String endpoint(ToolMetadata tool) {
if (tool.getEndpoint() != null && !tool.getEndpoint().isBlank()) {
return tool.getEndpoint();
}
if (tool.getPodUrl() == null || tool.getPodUrl().isBlank()) {
return "/mcp/" + tool.getName();
}
return tool.getPodUrl().replaceAll("/+$", "") + "/mcp/" + tool.getName();
}
private Map<String, Object> emptySchema() {
return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false);
}
private boolean isTrue(Boolean value) {
return Boolean.TRUE.equals(value);
}
private String defaultString(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.mcp.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className CacheConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
@EnableCaching
public class CacheConfig {
// 스프링이 캐시를 관리할 기본 저장소를 빈(Bean)으로 등록합니다.
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("eimsData"); // 아까 설정한 캐시 이름 등록
}
}

View File

@@ -0,0 +1,41 @@
package io.shinhanlife.dap.lib.mcp.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className JacksonConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class JacksonConfig {
// 1. JSON 변환기(ObjectMapper)를 스프링 Bean으로 등록
@Bean
@Primary
public ObjectMapper jsonMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
// 2. XML 변환기(XmlMapper)를 스프링 Bean으로 등록
@Bean
public XmlMapper xmlMapper() {
return new XmlMapper();
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.dap.lib.mcp.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.beans.factory.annotation.Value;
import java.util.HashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className KafkaLocalConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class KafkaLocalConfig {
@Value("${spring.kafka.bootstrap-servers:localhost:9092}")
private String bootstrapServers;
// 1. 카프카 전송 공장(Factory) 세팅
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
// 가짜 로컬 주소 혹은 환경변수 세팅
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
// 데이터를 카프카로 보낼 때 문자열(String) 형태로 변환하겠다는 규칙
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 3초 만에 빠른 실패 처리 (로컬 무한 대기 방지)
configProps.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 3000);
// 재접속 주기를 10초로 설정 (콘솔 로그 도배 방지)
configProps.put(ProducerConfig.RECONNECT_BACKOFF_MAX_MS_CONFIG, 10000);
return new DefaultKafkaProducerFactory<>(configProps);
}
// 2. EaiEimsSender가 애타게 찾던 KafkaTemplate을 스프링 Bean으로 등록!
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@@ -0,0 +1,47 @@
package io.shinhanlife.dap.lib.mcp.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className SwaggerConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Shinhan MCP Gateway API 명세서")
.version("v1.0")
.description("AI Agent와 신한라이프 내부망(EIMS/EAI)을 연결하는 Adapter Gateway API 문서입니다."))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8080").description("Adapter Pod (8080)"))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)"))
// 전역적으로 X-API-KEY 보안 설정을 Swagger UI에 추가합니다.
.addSecurityItem(new SecurityRequirement().addList("X-API-KEY"))
.components(new Components()
.addSecuritySchemes("X-API-KEY",
new SecurityScheme()
.name("X-API-KEY")
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.description("헤더에 API Key를 입력해주세요. ")));
}
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.lib.mcp.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import io.shinhanlife.dap.lib.mcp.security.ApiKeyInterceptor;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className WebConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
// 1. 우리가 만든 인터셉터를 주입받습니다.
private final ApiKeyInterceptor apiKeyInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 2. 인터셉터 등록 및 검사할 URL 패턴 지정
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/rpc/**", "/mcp/api/v1/**") // /rpc/, /mcp/api/v1/ 로 시작하는 모든 API는 API Key 검사 수행!
.excludePathPatterns(
"/test/**", "/health", "/error", "/mcp/api/v1/admin/**",
"/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", // Swagger UI 경로는 인증 제외
"/mcp/api/v1/tools/docs/markdown", "/favicon.ico", "/mcp/api/v1/tools/list"
);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Swagger UI(8080)에서 Gateway(8081)로 API 호출 시 발생하는 CORS 에러 해결
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Mcp-Session-Id")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,60 @@
package io.shinhanlife.dap.lib.mcp.exception;
/**
* @package io.shinhanlife.dap.lib.mcp.exception
* @className GlobalExceptionHandler
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.lib.adapter.dto.ErrorDetail;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@Slf4j
@RestControllerAdvice // 이 어노테이션이 전역 적용의 핵심입니다!
public class GlobalExceptionHandler {
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Void> handleNoResourceFound(NoResourceFoundException e) {
log.warn(" [Gateway Not Found] 요청하신 리소스를 찾을 수 없습니다: {}", e.getResourcePath());
return ResponseEntity.notFound().build();
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<JsonRpcResponse> handleIllegalArgument(IllegalArgumentException e) {
log.warn(" [Gateway Bad Request] 잘못된 요청: {}", e.getMessage());
return buildErrorResponse(-32602, "Invalid params: " + e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<JsonRpcResponse> handleRuntime(RuntimeException e) {
log.error(" [Gateway Internal Error] 시스템 장애: {}", e.getMessage(), e);
return buildErrorResponse(-32603, "Internal error: " + e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<JsonRpcResponse> handleAllException(Exception e) {
log.error(" [Gateway Fatal Error] 치명적 오류 발생", e);
return buildErrorResponse(-32000, "Server error: 시스템 관리자에게 문의하세요.");
}
private ResponseEntity<JsonRpcResponse> buildErrorResponse(int code, String message) {
JsonRpcResponse response = new JsonRpcResponse();
response.setError(new ErrorDetail(code, message));
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,56 @@
package io.shinhanlife.dap.lib.mcp.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* @package io.shinhanlife.dap.lib.mcp.filter
* @className MdcLoggingFilter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Component
public class MdcLoggingFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 클라이언트가 보낸 Trace ID가 있으면 쓰고, 없으면 새로 생성
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
// 간결하게 8자리 UUID만 사용
traceId = UUID.randomUUID().toString().substring(0, 8);
}
// 로깅 컨텍스트에 고유 ID 저장
MDC.put(MDC_KEY, traceId);
try {
// 이 요청이 처리되는 동안 찍히는 모든 log.info, log.error에 traceId가 자동으로 붙습니다.
filterChain.doFilter(request, response);
} finally {
// 메모리 누수 방지를 위해 요청이 끝나면 반드시 비워줍니다.
MDC.clear();
}
}
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.lib.mcp;
/** Holds optional MCP headers for the lifetime of one HTTP request thread. */
public final class McpRequestHeaderContext {
private static final ThreadLocal<McpRequestHeaders> CURRENT_HEADERS = new ThreadLocal<>();
private McpRequestHeaderContext() {
}
public static McpRequestHeaders current() {
return CURRENT_HEADERS.get();
}
static void set(McpRequestHeaders headers) {
CURRENT_HEADERS.set(headers);
}
static void clear() {
CURRENT_HEADERS.remove();
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.lib.mcp;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Captures optional correlation and employee headers for an MCP HTTP call. */
@Component
public class McpRequestHeaderFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().endsWith("/mcp");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
McpRequestHeaderContext.set(new McpRequestHeaders(
request.getHeader("X-Request-Id"),
request.getHeader("trace-id"),
request.getHeader("request-id"),
request.getHeader("employee-id")));
try {
filterChain.doFilter(request, response);
} finally {
McpRequestHeaderContext.clear();
}
}
}

View File

@@ -0,0 +1,9 @@
package io.shinhanlife.dap.lib.mcp;
/** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */
public record McpRequestHeaders(
String headerRequestId,
String traceId,
String requestId,
String encryptedEmployeeId) {
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.lib.mcp;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/** Exposes every Tool Pod through the MCP Streamable HTTP transport. */
@Configuration
public class ToolMcpServerConfiguration {
@Bean
@Primary
public HttpServletStreamableServerTransportProvider toolMcpTransportProvider() {
return HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.build();
}
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp");
}
}

View File

@@ -0,0 +1,81 @@
package io.shinhanlife.dap.lib.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.lib.usecase.ToolRegistryHeartbeatSender;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component
public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController;
private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) {
this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController;
this.objectMapper = objectMapper;
}
@EventListener(ApplicationReadyEvent.class)
public void registerLocalTools() {
heartbeatSender.getAllScannedTools().stream()
.filter(tool -> Boolean.TRUE.equals(tool.getVisible()))
.forEach(tool -> mcpServer.addTool(specification(tool)));
}
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema())
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build())
.build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
}
private McpSchema.CallToolResult invoke(String toolName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(
toolName,
requestHeaders == null ? null : requestHeaders.headerRequestId(),
requestHeaders == null ? null : requestHeaders.traceId(),
requestHeaders == null ? null : requestHeaders.requestId(),
requestHeaders == null ? null : requestHeaders.encryptedEmployeeId(),
arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build();
} catch (Exception error) {
return McpSchema.CallToolResult.builder().addTextContent(String.valueOf(body)).isError(failed).build();
}
}
private Map<String, Object> emptySchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", Map.of());
schema.put("additionalProperties", false);
return schema;
}
}

View File

@@ -0,0 +1,80 @@
package io.shinhanlife.dap.lib.mcp.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.mcp.security
* @className ApiKeyInterceptor
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiKeyInterceptor implements HandlerInterceptor {
// 1. 다중 테넌트 API Key 목록이 담긴 프로퍼티 객체를 주입받습니다.
private final SecurityProperties securityProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
Map<String, String> validApiKeys = securityProperties.getApiKeys();
// 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다.
if (validApiKeys == null || validApiKeys.isEmpty()) {
MDC.put("tenantId", "anonymous");
request.setAttribute("tenantId", "anonymous");
log.debug(" [보안 패스] 등록된 API Key 없음 - 익명 사용자(anonymous)로 통과");
return true;
}
// 3. 헤더로 들어온 API Key가 우리가 발급해준 목록(Map)에 존재하는지 확인합니다.
if (apiKey == null || !validApiKeys.containsKey(apiKey)) {
log.warn(" [보안 차단] 유효하지 않은 API Key 접근 시도 - IP: {}", request.getRemoteAddr());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid API Key");
return false; // 컨트롤러로 넘어가지 않음
}
// 4. 유효하다면 해당 키에 맵핑된 Tenant ID(식별자)를 가져옵니다. (ex. mcp-client-1)
String tenantId = validApiKeys.get(apiKey);
// 4. 추출한 Tenant ID를 현재 스레드의 로깅 컨텍스트(MDC)에 저장합니다.
// 이렇게 하면 이 요청이 끝날 때까지 찍히는 모든 로그에 어떤 테넌트가 호출했는지 자동으로 기록됩니다.
MDC.put("tenantId", tenantId);
// 5. 필요시 컨트롤러 로직에서 사용할 수 있도록 Request 속성에도 담아줍니다.
request.setAttribute("tenantId", tenantId);
log.debug(" [보안 통과] API Key 인증 성공 - 접속 테넌트: {}", tenantId);
return true; // 인증 통과! 컨트롤러로 진행
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다.
MDC.remove("tenantId");
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.mcp.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* [다중 테넌트 설정 매핑 클래스]
* application-local.properties 파일에 정의된 mcp.security.api-keys.* 설정들을
* Map 자료구조로 자동 바인딩(주입) 받기 위한 설정 클래스입니다.
*/
/**
* @package io.shinhanlife.dap.lib.mcp.security
* @className SecurityProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Component
@ConfigurationProperties(prefix = "mcp.security")
public class SecurityProperties {
// API Key를 Key로, Tenant ID를 Value로 가지는 맵
private Map<String, String> apiKeys = new HashMap<>();
// Tenant ID를 Key로, 허용된 도메인 그룹 목록을 Value로 가지는 맵 (ex. mcp-client-1 -> [CUSTOMER, COMMON])
// 만약 "ALL" 이 포함되어 있다면 모든 도메인에 접근 허용
private Map<String, List<String>> tenantDomains = new HashMap<>();
}

View File

@@ -0,0 +1,29 @@
package io.shinhanlife.dap.lib.session.converter;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* @package io.shinhanlife.dap.lib.session.converter
* @className ZtUsacConverter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public abstract class ZtUsacConverter {
@Mapping(target = "loginDtm", ignore = true)
@Mapping(target = "isManager", ignore = true)
public abstract SessionDto toSessionDto(ZtUsacOutDto dto);
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.dap.lib.session.domain.model;
import io.shinhanlife.glow.db.dto.AuditInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.session.domain.model
* @className ZtUsacModel
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ZtUsacModel extends AuditInfo {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.lib.session.domain.repository;
import io.shinhanlife.glow.GlowMybatisMapper;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
/**
* @package io.shinhanlife.dap.lib.session.domain.repository
* @className ZtUsacRepository
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@GlowMybatisMapper
public interface ZtUsacRepository {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,29 @@
package io.shinhanlife.dap.lib.session.domain.usecase;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
/**
* @package io.shinhanlife.dap.lib.session.domain.service
* @className ZtUsacUseCase
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public interface ZtUsacUseCase {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,42 @@
package io.shinhanlife.dap.lib.session.domain.usecase.impl;
import io.shinhanlife.dap.lib.session.domain.repository.ZtUsacRepository;
import io.shinhanlife.dap.lib.session.domain.usecase.ZtUsacUseCase;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* @package io.shinhanlife.dap.lib.session.domain.usecase.impl
* @className ZtUsacUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Service
@RequiredArgsConstructor
public class ZtUsacUseCaseImpl implements ZtUsacUseCase {
private final ZtUsacRepository ztUsacRepository;
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
@Override
public ZtUsacOutDto selectZtUsac(ZtUsacInDto dto) {
ZtUsacOutDto result = ztUsacRepository.selectZtUsac(dto);
result.initLists();
return result;
}
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className SessionDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SessionDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직번호 */
private String ognzNm;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
// 추가된 LICO 연동 공통 헤더 필수 필드들
private String strYmd;
private String brafNo;
private String psmrAsrtCd;
private String sbsnRulpAsrtCd;
private String bsduCd;
private String bsquCd;
private String ognzAsrtCd;
private String ognzLeveCd;
private String prgrId;
// 유틸성
private String loginDtm; // 로그인일시
private String isManager; // 관리자여부
public void setLoginDtm() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
this.loginDtm = LocalDateTime.now().format(formatter);
}
public void setIsManager(String isManager) {
// TODO 역할 필터링 후 관리자 여부 체크
this.isManager = "Y";
}
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className ZtUsacInDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ZtUsacInDto {
/* 인사번호 */
private String prafNo;
/* 사용여부 */
@Builder.Default
private String puseYn = "Y";
}

View File

@@ -0,0 +1,85 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.Setter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className ZtUsacOutDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Setter
public class ZtUsacOutDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직명 */
private String ognzNm;
/* 이메일주소 */
// @GlowSecureField(type = DataSecureType.DECRYPT_DB, direction = SafeDBType.COMM) TODO 방화벽 뚫리면 확인
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
/* 사용여부 */
private String puseYn;
private String roleNoStrList;
private String roleNmStrList;
private String tgtrPrafNoStrList;
private String tgtrOgnzNoStrList;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
public void initLists() {
this.roleNoList = convertStrToList(roleNoStrList);
this.roleNmList = convertStrToList(roleNmStrList);
this.tgtrPrafNoList = convertStrToList(tgtrPrafNoStrList);
this.tgtrOgnzNoList = convertStrToList(tgtrOgnzNoStrList);
}
private List<String> convertStrToList(String str) {
if (str == null || str.isEmpty()) {
return new ArrayList<>();
}
return Arrays.asList(str.split(","));
}
}

View File

@@ -0,0 +1,91 @@
package io.shinhanlife.dap.lib.session.presentation;
import io.micrometer.common.util.StringUtils;
import io.shinhanlife.glow.BaseResponse;
import io.shinhanlife.glow.BizException;
import io.shinhanlife.glow.GlowControllerId;
import io.shinhanlife.glow.ResponseUtil;
import io.shinhanlife.dap.lib.session.converter.ZtUsacConverter;
import io.shinhanlife.dap.lib.session.domain.usecase.ZtUsacUseCase;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import io.shinhanlife.dap.lib.session.presentation.io.SsoResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.javassist.NotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
/**
* @package io.shinhanlife.dap.lib.session.presentation
* @className SsoRestController
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/sso")
public class SsoRestController {
private static final String NLS_LOGIN_URL = "";
private final ZtUsacUseCase ztUsacService;
private final ZtUsacConverter ztUsacConverter;
/**
* sso 연동 전 임시 로그인
*
* @param request
* @param response
* @param session
* @param <T>
* @return
*/
@GlowControllerId("tempLogin")
@PostMapping("/tempLogin")
public <T> ResponseEntity<BaseResponse<SsoResponse>> tempLogin(HttpServletRequest request, HttpServletResponse response,
HttpSession session, @RequestBody SessionDto requestDto) {
try {
if (StringUtils.isEmpty(requestDto.getPrafNo())) {
throw new NotFoundException("SSO >> not found sso id");
}
// DB 유저 가져오기
ZtUsacOutDto ztUsacOutDto = ztUsacService.selectZtUsac(ZtUsacInDto.builder().prafNo(requestDto.getPrafNo()).puseYn("Y")
.build());
if (Objects.isNull(ztUsacOutDto) || StringUtils.isEmpty(ztUsacOutDto.getPrafNo())) {
throw new BizException("SSO >> not found UserInfo >> retCode:");
}
SessionDto sessionDto = ztUsacConverter.toSessionDto(ztUsacOutDto);
sessionDto.setLoginDtm(); // 로그인 시점 세팅
session.setAttribute("userInfo", sessionDto);
return ResponseUtil.ok(SsoResponse.builder().retCode("0").userInfo(sessionDto).build());
} catch (Exception e) {
log.error("로그인 실패", e);
session.invalidate();
}
return ResponseUtil.ok(SsoResponse.builder().redirectUrl(NLS_LOGIN_URL).build());
}
}

View File

@@ -0,0 +1,35 @@
package io.shinhanlife.dap.lib.session.presentation.io;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.presentation.io
* @className SsoResponse
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SsoResponse {
private String retCode;
private SessionDto userInfo;
private String redirectUrl;
}

View File

@@ -0,0 +1,95 @@
package io.shinhanlife.dap.lib.usecase;
import io.shinhanlife.dap.lib.adapter.connector.LegacyEimsConnector;
import io.shinhanlife.dap.lib.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.mcc.service
* @className AbstractMcpToolUseCase
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
public abstract class AbstractMcpToolUseCase {
@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;
}
}
}

View File

@@ -0,0 +1,189 @@
package io.shinhanlife.dap.lib.usecase;
/**
* @package io.shinhanlife.dap.mcc.service
* @className ToolRegistryHeartbeatSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
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();
private final ToolSchemaResolver toolSchemaResolver;
@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 스캔
McpTool toolAnnotation = AnnotationUtils.findAnnotation(targetClass, McpTool.class);
if (toolAnnotation == null) {
toolAnnotation = AnnotationUtils.findAnnotation(bean.getClass(), McpTool.class);
}
for (Method method : targetClass.getDeclaredMethods()) {
// 메서드, 수퍼클래스, 인터페이스를 모두 뒤져서 @McpFunction 스캔
McpFunction functionAnnotation = AnnotationUtils.findAnnotation(method, McpFunction.class);
if (functionAnnotation != null && toolAnnotation != 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(functionAnnotation.version());
meta.setTimeoutMillis(functionAnnotation.timeoutMillis());
meta.setEnabled(functionAnnotation.enabled());
meta.setDescription(functionAnnotation.description());
meta.setCategoryKey(toolAnnotation.categoryKey());
meta.setIntegrationType(toolAnnotation.routingType());
meta.setMciServiceId(functionAnnotation.mappingId());
meta.setPodUrl(podUrl);
meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName);
boolean isVisible = functionAnnotation.visible();
meta.setVisible(isVisible);
meta.setIsRegistered(isRegister);
meta.setRequiresApproval(functionAnnotation.requiresApproval());
meta.setReadOnlyHint(functionAnnotation.readOnlyHint());
meta.setDestructiveHint(functionAnnotation.destructiveHint());
meta.setIdempotentHint(functionAnnotation.idempotentHint());
meta.setOpenWorldHint(functionAnnotation.openWorldHint());
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> finalSchema = toolSchemaResolver.resolve(functionAnnotation, paramType);
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());
}
}
}

View File

@@ -0,0 +1,205 @@
package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import io.shinhanlife.dap.lib.annotation.McpAnyOf;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @package io.shinhanlife.dap.lib.util
* @className JsonSchemaGenerator
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class JsonSchemaGenerator {
/**
* Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다.
*/
public static Map<String, Object> generateSchema(Class<?> clazz) {
return generateSchema(clazz, new HashSet<>());
}
private static Map<String, Object> generateSchema(Class<?> clazz, Set<Class<?>> visiting) {
Map<String, Object> schema = new HashMap<>();
schema.put("type", "object");
schema.put("additionalProperties", false);
if (!visiting.add(clazz)) {
return schema;
}
Map<String, Object> properties = new HashMap<>();
List<String> requiredList = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
Map<String, Object> fieldSchema = createFieldSchema(field, visiting);
// 1. 타입 매핑
// 2. 어노테이션 기반 설명 추출
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
JsonPropertyDescription descAnnotation = field.getAnnotation(JsonPropertyDescription.class);
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
fieldSchema.put("description", paramAnnotation.description());
} else if (descAnnotation != null && !descAnnotation.value().isEmpty()) {
fieldSchema.put("description", descAnnotation.value());
} else {
fieldSchema.put("description", field.getName()); // 기본값
}
// 3. 필수 여부 판단
JsonProperty jsonProp = field.getAnnotation(JsonProperty.class);
if ((jsonProp != null && jsonProp.required()) || (paramAnnotation != null && paramAnnotation.required())) {
requiredList.add(field.getName());
}
McpValidation validation = field.getAnnotation(McpValidation.class);
if (validation != null && validation.required() && !requiredList.contains(field.getName())) {
requiredList.add(field.getName());
}
if (validation != null && !validation.pattern().isEmpty()) {
fieldSchema.put("pattern", validation.pattern());
}
if (validation != null && validation.minimum() != Long.MIN_VALUE) {
fieldSchema.put("minimum", validation.minimum());
}
if (validation != null && validation.maximum() != Long.MAX_VALUE) {
fieldSchema.put("maximum", validation.maximum());
}
if (validation != null && validation.minLength() >= 0) {
fieldSchema.put("minLength", validation.minLength());
}
if (validation != null && validation.maxLength() >= 0) {
fieldSchema.put("maxLength", validation.maxLength());
}
if (validation != null && validation.allowedValues().length > 0) {
fieldSchema.put("enum", List.of(validation.allowedValues()));
}
if (validation != null && !validation.format().isEmpty()) {
fieldSchema.put("format", validation.format());
}
if (validation != null && !validation.defaultValue().isEmpty()) {
fieldSchema.put("default", coerceDefaultValue(validation.defaultValue(), field.getType()));
}
if (validation != null && validation.examples().length > 0) {
fieldSchema.put("examples", List.of(validation.examples()));
}
if (validation != null && validation.nullable()) {
Map<String, Object> nonNullSchema = new HashMap<>(fieldSchema);
fieldSchema = new HashMap<>();
fieldSchema.put("anyOf", List.of(
nonNullSchema,
Map.of("type", "null")
));
}
properties.put(field.getName(), fieldSchema);
}
schema.put("properties", properties);
if (!requiredList.isEmpty()) {
schema.put("required", requiredList);
}
McpAnyOf anyOfAnnotation = clazz.getAnnotation(McpAnyOf.class);
if (anyOfAnnotation != null && anyOfAnnotation.value().length > 0) {
List<Map<String, Object>> anyOfList = new ArrayList<>();
for (String fieldName : anyOfAnnotation.value()) {
anyOfList.add(Map.of("required", List.of(fieldName)));
}
schema.put("anyOf", anyOfList);
}
visiting.remove(clazz);
return schema;
}
private static Object coerceDefaultValue(String value, Class<?> fieldType) {
try {
if (fieldType == Integer.class || fieldType == int.class
|| fieldType == Long.class || fieldType == long.class
|| fieldType == Short.class || fieldType == short.class
|| fieldType == Byte.class || fieldType == byte.class) {
return Long.valueOf(value);
}
if (fieldType == Double.class || fieldType == double.class
|| fieldType == Float.class || fieldType == float.class) {
return Double.valueOf(value);
}
if (fieldType == Boolean.class || fieldType == boolean.class) {
return Boolean.valueOf(value);
}
return value;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid MCP default value: " + value, e);
}
}
private static Map<String, Object> createFieldSchema(Field field, Set<Class<?>> visiting) {
Class<?> fieldType = field.getType();
if (isSimpleType(fieldType)) {
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(fieldType)));
}
if (List.class.isAssignableFrom(fieldType)) {
Map<String, Object> fieldSchema = new HashMap<>();
fieldSchema.put("type", "array");
fieldSchema.put("items", generateItemsSchema(field, visiting));
return fieldSchema;
}
return generateSchema(fieldType, visiting);
}
private static Map<String, Object> generateItemsSchema(Field field, Set<Class<?>> visiting) {
Type genericType = field.getGenericType();
if (genericType instanceof ParameterizedType parameterizedType) {
Type itemType = parameterizedType.getActualTypeArguments()[0];
if (itemType instanceof Class<?> itemClass) {
if (isSimpleType(itemClass)) {
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(itemClass)));
}
return generateSchema(itemClass, visiting);
}
}
return new HashMap<>(Map.of("type", "object"));
}
private static boolean isSimpleType(Class<?> clazz) {
return clazz == String.class
|| clazz == Integer.class || clazz == int.class
|| clazz == Long.class || clazz == long.class
|| clazz == Double.class || clazz == double.class
|| clazz == Float.class || clazz == float.class
|| clazz == Boolean.class || clazz == boolean.class;
}
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";
}
}

View File

@@ -0,0 +1,309 @@
package io.shinhanlife.dap.lib.util;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Scanner;
public class PodScaffolder {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("=========================================");
System.out.println(" MCP Tool Pod Scaffolder (Java CLI) ");
System.out.println("=========================================\n");
String rawModuleName = getOrAsk(args, 0, scanner, "1. 생성할 모듈(Pod) 이름 (예: payment 또는 dap-tool-payment): ");
String moduleName = rawModuleName.startsWith("dap-tool-") ? rawModuleName : "dap-tool-" + rawModuleName;
String portStr = getOrAsk(args, 1, scanner, "2. 사용할 포트 번호 (예: 8085): ");
String shortName = moduleName.replace("dap-tool-", "").replace("-", "");
String defaultAuthor = System.getProperty("user.name");
String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
String author = getOrAsk(args, 2, scanner, "3. 작성자 (엔터 입력 시 '" + defaultAuthor + "'): ");
if (author.trim().isEmpty()) author = defaultAuthor;
String createDate = getOrAsk(args, 3, scanner, "4. 작성일 (엔터 입력 시 '" + defaultDate + "'): ");
if (createDate.trim().isEmpty()) createDate = defaultDate;
String result = scaffoldPod(moduleName, portStr, shortName, author, createDate);
System.out.println(result);
}
private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) {
if (args.length > index) {
return args[index];
}
System.out.print(prompt);
return scanner.nextLine().trim();
}
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author, String createDate) throws IOException {
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
Path modulePath = rootDir.resolve(Paths.get(moduleName));
if (Files.exists(modulePath)) {
return "[오류] 이미 존재하는 모듈입니다: " + moduleName;
}
StringBuilder log = new StringBuilder();
log.append("[1/6] 모듈 디렉터리 생성 중...\n");
Files.createDirectories(modulePath);
log.append("[2/6] build.gradle 생성 중...\n");
String buildGradle = """
plugins {
id 'org.springframework.boot'
}
dependencies {
implementation project(':dap-was-lib')
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}
""";
Files.writeString(modulePath.resolve("build.gradle"), buildGradle);
log.append("[3/6] Dockerfile 생성 중...\n");
String dockerfile = """
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY build/libs/%s-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
""".formatted(moduleName);
Files.writeString(modulePath.resolve("Dockerfile"), dockerfile);
log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n");
Path srcPath = modulePath.resolve("src/main/java/io/shinhanlife/dap/mcc/" + shortName);
Files.createDirectories(srcPath);
String appClass = """
package io.shinhanlife.dap.mcc.%s;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cache.annotation.EnableCaching;
/**
* @package io.shinhanlife.dap.mcc.%s
* @className DapTool%sApplication
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dap.mcc", "io.shinhanlife.dap.lib.adapter", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config", "io.shinhanlife.dap.lib.integration"})
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dap.mcc", "io.shinhanlife.dap.lib.adapter", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config", "io.shinhanlife.dap.lib.integration"})
@EnableCaching
public class DapTool%sApplication {
public static void main(String[] args) {
SpringApplication.run(DapTool%sApplication.class, args);
}
}
""".formatted(shortName, shortName, capitalize(shortName), author, createDate, createDate, author, capitalize(shortName), capitalize(shortName));
Files.writeString(srcPath.resolve("DapTool" + capitalize(shortName) + "Application.java"), appClass);
Path resPath = modulePath.resolve("src/main/resources");
Files.createDirectories(resPath);
String applicationYml = """
server:
port: %s
spring:
application:
name: %s
profiles:
active: local
logging:
level:
org.apache.kafka: ERROR
mcp:
namespace: ""
security:
tenant-domains:
TESTER-DEV: ALL
""".formatted(portStr, moduleName);
Files.writeString(resPath.resolve("application.yml"), applicationYml);
String applicationLocalYml = """
# Local 환경 전용 설정 (H2 메모리 DB 등)
spring:
datasource:
url: jdbc:p6spy:h2:mem:testdb;DB_CLOSE_DELAY=-1;
driverClassName: com.p6spy.engine.spy.P6SpyDriver
username: sa
password: password
h2:
console:
enabled: true
eims:
http:
url: http://localhost:${server.port}/api/gateway
tcp:
host: 127.0.0.1
port: 8090
timeout: 5000
jsp:
form:
url: http://localhost:${server.port}/mock/jsp-form
json:
url: http://localhost:${server.port}/mock/jsp-json
mci:
url: http://localhost:${server.port}/api/mock/esb/api
mcistring:
url: http://localhost:${server.port}/api/mock/esb/string
mcp:
security:
tenant-domains:
mcp-client-1: CUSTOMER,COMMON
mcp-client-2: ALL
axhub:
gateway:
url: http://localhost:8081
tool:
url: ${AXHUB_TOOL_URL:http://localhost:${server.port}}
""";
Files.writeString(resPath.resolve("application-local.yml"), applicationLocalYml);
String applicationDevYml = """
# OCI 클라우드 환경 전용 설정
server:
port: ${PORT:%s}
axhub:
gateway:
url: https://axhubmcp.devjun.net
tool:
url: http://144.24.70.100:%s
eims:
http:
url: http://localhost:${server.port}/api/gateway
tcp:
host: 127.0.0.1
port: 8090
timeout: 5000
jsp:
form:
url: http://localhost:${server.port}/mock/jsp-form
json:
url: http://localhost:${server.port}/mock/jsp-json
mci:
url: http://localhost:${server.port}/api/mock/esb/api
mcistring:
url: http://localhost:${server.port}/api/mock/esb/string
shinhan:
integration:
envrTypeCd: D
eai:
url: http://10.176.32.181
internalMci:
url: http://10.176.32.173
bancaMci:
url: http://10.176.32.117
externalMci:
url: http://10.176.32.176
""".formatted(portStr, portStr);
Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml);
String logbackXml = """
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%%d{yyyy-MM-dd HH:mm:ss.SSS} [%%thread] [%%X{traceId}] %%-5level %%logger{36} - %%msg%%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/%s.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/%s-%%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="io.shinhanlife" level="DEBUG" />
</configuration>
""".formatted(moduleName, moduleName);
Files.writeString(resPath.resolve("logback-spring.xml"), logbackXml);
log.append("[5/6] settings.gradle 에 모듈 등록 중...\n");
Path settingsPath = rootDir.resolve(Paths.get("settings.gradle"));
if (Files.exists(settingsPath)) {
String settings = Files.readString(settingsPath);
if (!settings.contains("include '" + moduleName + "'")) {
Files.writeString(settingsPath, System.lineSeparator() + "include '" + moduleName + "'" + System.lineSeparator(), StandardOpenOption.APPEND);
}
}
log.append("[6/6] docker-compose.yml 에 서비스 추가 중...\n");
Path dockerComposePath = rootDir.resolve(Paths.get("docker-compose.yml"));
if (Files.exists(dockerComposePath)) {
String compose = Files.readString(dockerComposePath);
String serviceName = moduleName.replace("dap-", ""); // e.g. tool-payment
if (!compose.contains(" " + serviceName + ":")) {
String newService = """
%s:
build:
context: .
dockerfile: %s/Dockerfile
ports:
- "%s:%s"
depends_on:
- redis
environment:
- TZ=Asia/Seoul
- SPRING_REDIS_HOST=redis
- SPRING_REDIS_PORT=6379
- SPRING_DATA_REDIS_PORT=6379
- AXHUB_GATEWAY_URL=http://gateway:8081
- AXHUB_TOOL_URL=http://%s:%s
- GLOW_COMMUNICATION_MCI_HOST=http://mci-mock
- GLOW_COMMUNICATION_MCI_PORT=8080
- GLOW_COMMUNICATION_EXTMCI_HOST=http://mci-mock
- GLOW_COMMUNICATION_EXTMCI_PORT=8080
- GLOW_COMMUNICATION_EAI_HOST=http://mci-mock
- GLOW_COMMUNICATION_EAI_PORT=8080
""".formatted(serviceName, moduleName, portStr, portStr, serviceName, portStr);
Files.writeString(dockerComposePath, System.lineSeparator() + newService, StandardOpenOption.APPEND);
}
}
log.append("\n=========================================\n");
log.append(" Pod Scaffolding Complete! \n");
log.append("=========================================\n");
log.append("1. [새로운 모듈] ").append(moduleName).append(" 폴더가 생성되었습니다.\n");
log.append("2. [ToolScaffolder]를 사용해 이 모듈 안에 툴을 추가하세요.\n");
log.append("3. 실행 전 Gradle 동기화(Sync)를 한 번 진행해 주세요.\n");
return log.toString();
}
private static String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dap.lib.util;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* @package io.shinhanlife.dap.lib.util
* @className SessionUtil
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class SessionUtil {
private static final String SESSION_KEY = "userInfo";
private SessionUtil() {}
public static SessionDto getSession() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return null;
HttpSession session = attributes.getRequest().getSession(false);
if (session == null) return null;
return (SessionDto) session.getAttribute(SESSION_KEY);
}
public static String getPrafNo() {
SessionDto session = getSession();
return session != null ? session.getPrafNo() : null;
}
public static String getOgnzNo() {
SessionDto session = getSession();
return session != null ? session.getOgnzNo() : null;
}
}

View File

@@ -0,0 +1,714 @@
package io.shinhanlife.dap.lib.util;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Scanner;
/**
* MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스
*
* [실행 방법]
* 방법 1. IDE(IntelliJ 등)에서 직접 실행 (대화형 모드 추천 ⭐)
* - 이 클래스(ToolScaffolder.java)를 열고 main 메서드를 직접 실행(Run)합니다.
* - 콘솔 창에 뜨는 질문에 차례대로 값을 입력하기만 하면 파일이 생성됩니다.
*
* 방법 2. 커맨드라인(터미널)에서 실행 (명령어 기반)
* - 컴파일: javac -encoding UTF-8 dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/util/ToolScaffolder.java
* - 실행: java -cp dap-was-lib/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [이름] [ID] "[설명]" "[그룹]" "[통신방식]" "[모듈명]"
*/
/**
* @package io.shinhanlife.dap.lib.util
* @className ToolScaffolder
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class ToolScaffolder {
private static final String BASE_PACKAGE = "io.shinhanlife.dap.mcc";
private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/mcc";
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("=========================================");
System.out.println(" MCP Tool Scaffolder (Java CLI) ");
System.out.println("=========================================\n");
String baseName = getOrAsk(args, 0, scanner, "1. 생성할 Tool의 기본 이름 (예: ExchangeRate) [영문 PascalCase]: ");
String interfaceId = getOrAsk(args, 1, scanner, "2. 레거시 API 인터페이스 ID (예: EXCH_001): ");
String description = getOrAsk(args, 2, scanner, "3. Tool 기능 설명 (예: 환율 조회): ");
String group = getOrAsk(args, 3, scanner, "4. Tool 소속 그룹 (예: SAMPLE, NOTIFICATION, CLAIM, POLICY, HR, CONTRACT, CUSTOMER 등): ");
if (group.isEmpty()) group = "COMMON";
String routingType = getOrAsk(args, 4, scanner, "5. 통신 프로토콜 (예: HTTP, TCP, MCI, EAI): ");
if (routingType.trim().isEmpty()) {
routingType = "HTTP";
}
String moduleName = getOrAsk(args, 5, scanner, "6. 코드를 생성할 모듈 (기본: dap-was-oth): ");
if (moduleName.trim().isEmpty()) {
moduleName = "dap-was-oth";
}
String defaultAuthor = System.getProperty("user.name");
String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
String author = getOrAsk(args, 6, scanner, "7. 작성자 (엔터 입력 시 '" + defaultAuthor + "'): ");
if (author.trim().isEmpty()) author = defaultAuthor;
String createDate = getOrAsk(args, 7, scanner, "8. 작성일 (엔터 입력 시 '" + defaultDate + "'): ");
if (createDate.trim().isEmpty()) createDate = defaultDate;
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true, null);
System.out.println(result);
}
private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) {
if (args.length > index) {
return args[index];
}
System.out.print(prompt);
return scanner.nextLine().trim();
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode) throws IOException {
baseName = toPascalCase(baseName);
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
Path usecaseDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "usecase"));
Path usecaseImplDir = usecaseDir.resolve("impl");
Path dtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "dto"));
Path legacyDtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "legacy"));
Path converterDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "converter"));
String bizPackage = BASE_PACKAGE + ".biz." + group.toLowerCase();
boolean isMci = "MCI".equalsIgnoreCase(routingType);
String mciGroupPath = "infra/itrf/mci/" + group.toLowerCase();
String clientPkgSuffix = "";
String clientPrefixCap = "";
Path mciClientDir = null;
if (isMci && clientSystemCode != null && clientSystemCode.length() == 4) {
String clientPrefix = clientSystemCode.toLowerCase();
clientPkgSuffix = clientPrefix.substring(0, 3) + "." + clientPrefix.substring(3, 4);
clientPrefixCap = toPascalCase(clientSystemCode);
mciGroupPath = "infra/itrf/mci/" + clientPrefix.substring(0, 3) + "/" + clientPrefix.substring(3, 4);
mciClientDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, mciGroupPath));
}
Path mciIoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, mciGroupPath, "io"));
Files.createDirectories(usecaseDir);
Files.createDirectories(usecaseImplDir);
Files.createDirectories(dtoDir);
if (isMci) {
Files.createDirectories(mciIoDir);
if (mciClientDir != null) {
Files.createDirectories(mciClientDir);
}
} else {
Files.createDirectories(legacyDtoDir);
}
Files.createDirectories(converterDir);
StringBuilder log = new StringBuilder();
// Generate Request DTO
String reqContent = """
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import lombok.Data;
/**
* @package %s.dto
* @className %sRequest
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sRequest {
@McpParameter(description = "수신자 전화번호", required = true)
@McpValidation(pattern = "^01(?:0|1|[6-9])-(?:\\\\d{3}|\\\\d{4})-\\\\d{4}$", examples = {"010-1234-5678"})
private String phoneNumber;
@McpParameter(description = "전송할 메시지 내용", required = true)
@McpValidation(defaultValue = "안녕하세요.")
private String message;
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
Files.writeString(dtoDir.resolve(baseName + "Request.java"), reqContent);
// Generate Response DTO
String resContent = """
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import lombok.Data;
/**
* @package %s.dto
* @className %sResponse
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
@McpOutputSchema
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sResponse {
@McpValidation(required = true)
private String resultCode;
@McpValidation(maxLength = 200, nullable = true)
private String resultMessage;
// TODO: Add response fields here. Do not include PII in the Tool response.
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
String toolName = toToolName(moduleName, group, baseName);
String serviceInterfaceContent = """
package %s.usecase;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import %s.dto.%sRequest;
@McpTool(
routingType = "%s",
categoryKey = "%s"
)
public interface %sUseCase {
@McpFunction(
displayName = "%s 툴",
name = "%s",
description = "%s",
prompt = "%s",
mappingId = "%s",
register = %s,
requiresApproval = false,
openWorldHint = true,
version = "1.0.0",
timeoutMillis = 300000L,
enabled = true
)
Object execute(%sRequest req);
}
""".formatted(
bizPackage,
bizPackage, baseName,
routingType, group.toLowerCase(),
baseName,
baseName, toolName, description, description + " ?줘.", interfaceId, register,
baseName
);
Files.writeString(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent);
String serviceImplContent;
if (isMci) {
serviceImplContent = """
package %s.usecase.impl;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.usecase.%sUseCase;
import io.shinhanlife.dap.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import %s.converter.%sConverter;
import %s.%s.io.%s_I;
%s
/**
* @package %s.usecase.impl
* @className %sUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class %sUseCaseImpl implements %sUseCase {
%s
private final %sConverter converter;
@Override
public Object execute(%sRequest req) {
log.info("[MCI Tool] {} 요청 수신.", "%s");
try {
// MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO)
%s_I mciReq = converter.toLegacyRequest(req);
Transfer<Object> resTransfer = mci.callTo(
"%s",
null,
mciReq,
Object.class
);
return resTransfer.getBody() != null ? resTransfer.getBody() : Map.of("status", "SUCCESS");
} catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
return Map.of("status", "ERROR", "message", e.getMessage() != null ? e.getMessage() : "Unknown error");
}
}
}
""".formatted(
bizPackage,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId,
(clientPrefixCap.isEmpty() ? "" : "import " + BASE_PACKAGE + "." + mciGroupPath.replace("/", ".") + ".Mci" + clientPrefixCap + "Client;\n"),
bizPackage,
baseName,
author,
createDate,
createDate, author,
baseName,
baseName,
(clientPrefixCap.isEmpty() ? "private final AxhubMciComponent mci;" : "private final Mci" + clientPrefixCap + "Client mci;"),
baseName,
baseName,
toolName,
interfaceId,
interfaceId
);
} else {
serviceImplContent = """
package %s.usecase.impl;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.usecase.%sUseCase;
import io.shinhanlife.dap.lib.usecase.AbstractMcpToolUseCase;
import %s.converter.%sConverter;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* @package %s.usecase.impl
* @className %sUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class %sUseCaseImpl extends AbstractMcpToolUseCase implements %sUseCase {
private final %sConverter converter;
@Override
public Object execute(%sRequest req) {
// %sLegacyRequest legacyRequest = converter.toLegacyRequest(req);
return executeLegacy("%s", "%s", req); // Or pass legacyRequest
}
}
""".formatted(
bizPackage,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
author,
createDate,
createDate, author,
baseName, baseName,
baseName,
baseName,
baseName,
routingType, interfaceId
);
}
Files.writeString(usecaseImplDir.resolve(baseName + "UseCaseImpl.java"), serviceImplContent);
if (isMci) {
String mciReqContent = """
package %s.%s.io;
import lombok.Data;
/**
* @package %s.%s.io
* @className %s_I
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
public class %s_I {
/**
* EAI 시스템이 요구하는 수신자 번호 파라미터명
*/
private String phone;
/**
* EAI 시스템이 요구하는 메시지 내용 파라미터명
*/
private String content;
}
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
Files.writeString(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent);
String mciResContent = """
package %s.%s.io;
import lombok.Data;
/**
* @package %s.%s.io
* @className %s_O
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
public class %s_O {
// TODO: Add response fields here
}
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
Files.writeString(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent);
String converterContent = """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.%s.io.%s_I;
import %s.%s.io.%s_O;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
/**
* @package %s.converter
* @className %sConverter
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public interface %sConverter {
@Mapping(source = "phoneNumber", target = "phone")
@Mapping(source = "message", target = "content")
%s_I toLegacyRequest(%sRequest req);
@Mapping(source = "phone", target = "phoneNumber")
@Mapping(source = "content", target = "message")
%sRequest toRequest(%s_I mciReq);
// %sResponse toResponse(%s_O mciRes);
}
""".formatted(
bizPackage,
bizPackage, baseName,
bizPackage, baseName,
BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId,
BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId,
bizPackage, baseName, author, createDate, createDate, author,
baseName, interfaceId, baseName,
baseName, interfaceId,
baseName, interfaceId
);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n");
log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n");
log.append("=========================================\n");
log.append("[Usecase Interface] ").append(usecaseDir.resolve(baseName + "UseCase.java")).append("\n");
log.append("[Usecase Impl] ").append(usecaseImplDir.resolve(baseName + "UseCaseImpl.java")).append("\n");
log.append("[Request DTO] ").append(dtoDir.resolve(baseName + "Request.java")).append("\n");
log.append("[Response DTO] ").append(dtoDir.resolve(baseName + "Response.java")).append("\n");
log.append("[MCI Request IO] ").append(mciIoDir.resolve(interfaceId + "_I.java")).append("\n");
log.append("[MCI Response IO] ").append(mciIoDir.resolve(interfaceId + "_O.java")).append("\n");
log.append("[MCI Converter] ").append(converterDir.resolve(baseName + "Converter.java")).append("\n");
if (!clientPrefixCap.isEmpty()) {
String mciClientContent = """
package %s.%s;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import io.shinhanlife.dap.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
/**
* @package %s.%s
* @className Mci%sClient
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Component
@RequiredArgsConstructor
public class Mci%sClient {
private final AxhubMciComponent mci;
public Transfer<Object> callTo(String interfaceId, String dummy, Object mciReq, Class<Object> resType) throws Exception {
return mci.callTo(interfaceId, dummy, mciReq, resType);
}
}
""".formatted(
BASE_PACKAGE, mciGroupPath.replace("/", "."),
BASE_PACKAGE, mciGroupPath.replace("/", "."), clientPrefixCap, author, createDate, createDate, author, clientPrefixCap
);
Files.writeString(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java"), mciClientContent);
log.append("[MCI Client] ").append(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java")).append("\n");
}
} else {
String legacyReqContent = """
package %s.legacy;
import lombok.Data;
/**
* @package %s.legacy
* @className %sLegacyRequest
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
public class %sLegacyRequest {
/**
* EAI 시스템이 요구하는 수신자 번호 파라미터명
*/
private String phone;
/**
* EAI 시스템이 요구하는 메시지 내용 파라미터명
*/
private String content;
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent);
String legacyResContent = """
package %s.legacy;
import lombok.Data;
/**
* @package %s.legacy
* @className %sLegacyResponse
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Data
public class %sLegacyResponse {
// TODO: Add legacy response fields here
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent);
String converterContent = """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.legacy.%sLegacyRequest;
import %s.legacy.%sLegacyResponse;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
/**
* @package %s.converter
* @className %sConverter
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public interface %sConverter {
@Mapping(source = "phoneNumber", target = "phone")
@Mapping(source = "message", target = "content")
%sLegacyRequest toLegacyRequest(%sRequest req);
@Mapping(source = "phone", target = "phoneNumber")
@Mapping(source = "content", target = "message")
%sRequest toRequest(%sLegacyRequest legacyRequest);
// %sResponse toResponse(%sLegacyResponse legacyResponse);
}
""".formatted(
bizPackage,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName,
bizPackage, baseName, author, createDate, createDate, author,
baseName, baseName, baseName, baseName, baseName, baseName, baseName
);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n");
log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n");
log.append("=========================================\n");
log.append("[Usecase Interface] ").append(usecaseDir.resolve(baseName + "UseCase.java")).append("\n");
log.append("[Usecase Impl] ").append(usecaseImplDir.resolve(baseName + "UseCaseImpl.java")).append("\n");
log.append("[Request DTO] ").append(dtoDir.resolve(baseName + "Request.java")).append("\n");
log.append("[Response DTO] ").append(dtoDir.resolve(baseName + "Response.java")).append("\n");
log.append("[Legacy Request DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyRequest.java")).append("\n");
log.append("[Legacy Response DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyResponse.java")).append("\n");
log.append("[Legacy Converter] ").append(converterDir.resolve(baseName + "Converter.java")).append("\n");
}
log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n");
return log.toString();
}
private static String toToolName(String moduleName, String group, String baseName) {
String pod = moduleName.startsWith("dap-tool-")
? moduleName.substring("dap-tool-".length())
: "oth";
String normalizedName = baseName.replaceAll("([a-z0-9])([A-Z])", "$1 $2")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", " ")
.trim();
String[] words = normalizedName.split("\\s+");
String service = words[0];
String action = words.length == 1 ? "execute" : words[words.length - 1];
return "%s.%s.%s.%s".formatted(
pod.toLowerCase(Locale.ROOT),
group.toLowerCase(Locale.ROOT),
service,
action);
}
private static String toPascalCase(String str) {
if (str == null || str.isEmpty()) {
return str;
}
StringBuilder result = new StringBuilder();
boolean capitalizeNext = true;
for (char c : str.toCharArray()) {
if (c == '_' || c == '-' || c == ' ') {
capitalizeNext = true;
} else if (capitalizeNext) {
result.append(Character.toUpperCase(c));
capitalizeNext = false;
} else {
result.append(c);
}
}
if (result.length() > 0) {
result.setCharAt(0, Character.toUpperCase(result.charAt(0)));
}
return result.toString();
}
}

View File

@@ -0,0 +1,78 @@
package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;import java.io.InputStream;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
public class ToolSchemaResolver {
private final ObjectMapper objectMapper;
public ToolSchemaResolver(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Map<String, Object> resolve(McpFunction function, Class<?> requestType) {
if (function != null && !function.inputSchemaResource().isBlank()) {
return loadResource(function.inputSchemaResource());
}
if (function != null && !function.inputSchema().isBlank()
&& !"{}".equals(function.inputSchema().trim())) {
return parse(function.inputSchema(), "McpFunction.inputSchema");
}
return JsonSchemaGenerator.generateSchema(requestType);
}
/**
* Resolves an explicitly declared response schema.
* Response schemas are opt-in so existing tools keep their current response behavior.
*/
public Map<String, Object> resolveOutput(McpFunction function, Class<?> responseType) {
if (function != null && !function.outputSchemaResource().isBlank()) {
return loadResource(function.outputSchemaResource());
}
if (function != null && !function.outputSchema().isBlank()
&& !"{}".equals(function.outputSchema().trim())) {
return parse(function.outputSchema(), "McpFunction.outputSchema");
}
if (responseType != null && responseType.isAnnotationPresent(McpOutputSchema.class)) {
return JsonSchemaGenerator.generateSchema(responseType);
}
return Map.of();
}
/**
* Retained for callers that use only explicit output schemas.
*/
public Map<String, Object> resolveOutput(McpFunction function) {
return resolveOutput(function, null);
}
private Map<String, Object> loadResource(String location) {
String path = location.startsWith("classpath:")
? location.substring("classpath:".length())
: location;
ClassPathResource resource = new ClassPathResource(path);
if (!resource.exists()) {
throw new IllegalStateException("MCP schema resource not found: " + location);
}
try (InputStream inputStream = resource.getInputStream()) {
return objectMapper.readValue(inputStream, new TypeReference<>() { });
} catch (Exception e) {
throw new IllegalStateException("Failed to load MCP schema resource: " + location, e);
}
}
private Map<String, Object> parse(String schema, String source) {
try {
return objectMapper.readValue(schema, new TypeReference<>() { });
} catch (Exception e) {
throw new IllegalStateException("Failed to parse MCP input schema from " + source, e);
}
}
}

View File

@@ -0,0 +1,123 @@
package io.shinhanlife.dap.lib.util;
/**
* @package io.shinhanlife.dap.lib.util
* @className ToolSourceUpdater
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class ToolSourceUpdater {
public static void updateToolSource(String toolName, String domainGroup, String description, boolean register, Boolean requiresApproval) throws Exception {
// 1. Find all *UseCase.java files in dap-tool-* directories
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
List<Path> javaFiles;
try (Stream<Path> paths = Files.walk(rootDir)) {
javaFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith("UseCase.java"))
.filter(p -> p.toString().contains("dap-tool-") || p.toString().contains("axhub-tool-"))
.collect(Collectors.toList());
}
Path targetFile = null;
String content = null;
// 2. Find the specific file for the tool
String functionName = toolName;
if (toolName.contains("_")) {
functionName = toolName.substring(toolName.indexOf("_") + 1);
}
Pattern namePattern = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"", Pattern.DOTALL);
Pattern namePattern2 = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(functionName) + "\"", Pattern.DOTALL);
for (Path path : javaFiles) {
String text = Files.readString(path);
if (namePattern.matcher(text).find()) {
targetFile = path;
content = text;
break;
} else if (namePattern2.matcher(text).find()) {
targetFile = path;
content = text;
toolName = functionName; // Use baseName for subsequent replacements
break;
}
}
if (targetFile == null) {
throw new Exception("소스 코드를 찾을 수 없습니다: " + toolName);
}
// 3. Update @McpTool group
if (domainGroup != null && !domainGroup.trim().isEmpty()) {
Pattern groupPattern = Pattern.compile("(@McpTool\\s*\\([^)]*group\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
Matcher groupMatcher = groupPattern.matcher(content);
if (groupMatcher.find()) {
content = groupMatcher.replaceFirst("$1" + domainGroup + "$3");
}
}
// 4. Update @McpFunction description
if (description != null) {
Pattern funcPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*description\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
Matcher funcMatcher = funcPattern.matcher(content);
if (funcMatcher.find()) {
content = funcMatcher.replaceFirst("$1" + description.replace("\\", "\\\\").replace("$", "\\\\$") + "$3");
}
}
// 5. Update register flag
Pattern regPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*register\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
Matcher regMatcher = regPattern.matcher(content);
if (regMatcher.find()) {
content = regMatcher.replaceFirst("$1" + register + "$3");
} else {
Pattern addRegPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
Matcher addRegMatcher = addRegPattern.matcher(content);
if (addRegMatcher.find()) {
content = addRegMatcher.replaceFirst("$1, register = " + register);
}
}
// 5.5 Update requiresApproval flag
if (requiresApproval != null) {
Pattern appPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*requiresApproval\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
Matcher appMatcher = appPattern.matcher(content);
if (appMatcher.find()) {
content = appMatcher.replaceFirst("$1" + requiresApproval + "$3");
} else {
Pattern addAppPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
Matcher addAppMatcher = addAppPattern.matcher(content);
if (addAppMatcher.find()) {
content = addAppMatcher.replaceFirst("$1, requiresApproval = " + requiresApproval);
}
}
}
// 6. Write back to file
Files.writeString(targetFile, content);
}
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.lib.validation;
import java.nio.file.Path;
/** Gradle entry point for validating unique MCP Tool names before packaging. */
public final class McpToolNameValidationRunner {
private McpToolNameValidationRunner() {
}
public static void main(String[] args) {
if (args.length != 1) {
throw new IllegalArgumentException("Usage: McpToolNameValidationRunner <project-root>");
}
validate(Path.of(args[0]));
}
static void validate(Path projectRoot) {
McpToolNameValidator.assertUnique(projectRoot);
}
}

View File

@@ -0,0 +1,173 @@
package io.shinhanlife.dap.lib.validation;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @package io.shinhanlife.dap.lib.validation
* @className McpToolNameValidator
* @description Validates unique MCP function names across tool modules
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
public final class McpToolNameValidator {
private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\"");
private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$");
private McpToolNameValidator() {
}
public static void assertUnique(Path projectRoot) {
Map<String, List<ToolDeclaration>> declarationsByName = new LinkedHashMap<>();
try (var modules = Files.list(projectRoot)) {
modules.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().startsWith("dap-tool-"))
.filter(path -> !path.getFileName().toString().equals("dap-was-lib"))
.sorted()
.forEach(module -> collectDeclarations(module, declarationsByName));
} catch (IOException exception) {
throw new UncheckedIOException("Failed to scan MCP tool modules", exception);
}
List<Map.Entry<String, List<ToolDeclaration>>> invalidNames = declarationsByName.entrySet().stream()
.filter(entry -> !TOOL_NAME_CONVENTION.matcher(entry.getKey()).matches())
.sorted(Map.Entry.comparingByKey())
.toList();
if (!invalidNames.isEmpty()) {
throw new IllegalStateException(buildInvalidNameMessage(invalidNames));
}
List<Map.Entry<String, List<ToolDeclaration>>> duplicates = declarationsByName.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.sorted(Map.Entry.comparingByKey())
.toList();
if (!duplicates.isEmpty()) {
throw new IllegalStateException(buildDuplicateMessage(duplicates));
}
}
private static void collectDeclarations(Path module, Map<String, List<ToolDeclaration>> declarationsByName) {
Path sourceDirectory = module.resolve("src/main/java");
if (!Files.isDirectory(sourceDirectory)) {
return;
}
try (var sources = Files.walk(sourceDirectory)) {
sources.filter(path -> path.toString().endsWith(".java"))
.sorted()
.forEach(source -> collectDeclarations(module.getFileName().toString(), source, declarationsByName));
} catch (IOException exception) {
throw new UncheckedIOException("Failed to scan module " + module.getFileName(), exception);
}
}
private static void collectDeclarations(String moduleName, Path source,
Map<String, List<ToolDeclaration>> declarationsByName) {
String content;
try {
content = Files.readString(source);
} catch (IOException exception) {
throw new UncheckedIOException("Failed to read " + source, exception);
}
int annotationOffset = content.indexOf("@McpFunction");
while (annotationOffset >= 0) {
int openingParenthesis = content.indexOf('(', annotationOffset);
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
if (openingParenthesis < 0 || closingParenthesis < 0) {
break;
}
Matcher matcher = TOOL_NAME_PATTERN.matcher(content.substring(openingParenthesis + 1, closingParenthesis));
if (matcher.find()) {
String toolName = matcher.group(1);
int line = 1 + (int) content.substring(0, annotationOffset).chars().filter(character -> character == '\n').count();
declarationsByName.computeIfAbsent(toolName, ignored -> new ArrayList<>())
.add(new ToolDeclaration(moduleName, source, line));
}
annotationOffset = content.indexOf("@McpFunction", closingParenthesis + 1);
}
}
private static int findAnnotationEnd(String content, int openingParenthesis) {
if (openingParenthesis < 0) {
return -1;
}
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int index = openingParenthesis; index < content.length(); index++) {
char character = content.charAt(index);
if (inString) {
if (escaped) {
escaped = false;
} else if (character == '\\') {
escaped = true;
} else if (character == '\"') {
inString = false;
}
continue;
}
if (character == '\"') {
inString = true;
} else if (character == '(') {
depth++;
} else if (character == ')' && --depth == 0) {
return index;
}
}
return -1;
}
private static String buildInvalidNameMessage(List<Map.Entry<String, List<ToolDeclaration>>> invalidNames) {
StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected pod.domain.service.action using lowercase letters, digits, or hyphens.");
for (Map.Entry<String, List<ToolDeclaration>> invalidName : invalidNames) {
message.append("\n\n").append(invalidName.getKey());
invalidName.getValue().stream()
.sorted(Comparator.comparing(ToolDeclaration::moduleName).thenComparing(declaration -> declaration.source().toString()))
.forEach(declaration -> message.append("\n- ")
.append(declaration.moduleName())
.append(": ")
.append(declaration.source())
.append(':').append(declaration.line()));
}
return message.toString();
}
private static String buildDuplicateMessage(List<Map.Entry<String, List<ToolDeclaration>>> duplicates) {
StringBuilder message = new StringBuilder("Duplicate MCP tool name(s):");
for (Map.Entry<String, List<ToolDeclaration>> duplicate : duplicates) {
message.append("\n\n").append(duplicate.getKey());
duplicate.getValue().stream()
.sorted(Comparator.comparing(ToolDeclaration::moduleName).thenComparing(declaration -> declaration.source().toString()))
.forEach(declaration -> message.append("\n- ")
.append(declaration.moduleName())
.append(": ")
.append(declaration.source())
.append(':').append(declaration.line()));
}
return message.toString();
}
private record ToolDeclaration(String moduleName, Path source, int line) {
}
}

Some files were not shown because too many files have changed in this diff Show More