Migrate functional features from shinhan-mcp-prebuild to axhub-backend-main
This commit is contained in:
@@ -65,6 +65,13 @@ dependencies {
|
||||
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
|
||||
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
|
||||
|
||||
// MCP required dependencies
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||
implementation 'com.networknt:json-schema-validator:1.4.0'
|
||||
implementation 'org.springframework.kafka:spring-kafka:3.2.0'
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
||||
|
||||
// Test
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test:${springBootVersion}"
|
||||
}
|
||||
|
||||
33
migrate2.ps1
Normal file
33
migrate2.ps1
Normal file
@@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$source = "C:\Users\Admin\.gemini\antigravity\scratch\shinhan-mcp-prebuild\src\main\java\com\shinhan\mcp"
|
||||
$dest = "C:\Users\Admin\.gemini\antigravity\scratch\axhub-backend-main\src\main\java\io\shinhanlife\axhub\biz\mcp"
|
||||
$commonDest = "C:\Users\Admin\.gemini\antigravity\scratch\axhub-backend-main\src\main\java\io\shinhanlife\axhub\common\mcp"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $commonDest | Out-Null
|
||||
|
||||
Copy-Item -Path "$source\gateway" -Destination $dest -Recurse -Force
|
||||
Copy-Item -Path "$source\tool" -Destination $dest -Recurse -Force
|
||||
Copy-Item -Path "$source\adapter" -Destination $dest -Recurse -Force
|
||||
Copy-Item -Path "$source\common\*" -Destination $commonDest -Recurse -Force
|
||||
|
||||
# We also need to delete the leftover Application classes from shinhan-mcp-prebuild
|
||||
Remove-Item -Path "$dest\gateway\GatewayApplication.java" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "$dest\tool\ToolApplication.java" -ErrorAction SilentlyContinue
|
||||
|
||||
# Replace package names safely with UTF-8
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
Get-ChildItem -Path $dest, $commonDest -Filter *.java -Recurse | ForEach-Object {
|
||||
$content = [System.IO.File]::ReadAllText($_.FullName, $utf8)
|
||||
|
||||
$content = $content -replace "package com.shinhan.mcp.common", "package io.shinhanlife.axhub.common.mcp"
|
||||
$content = $content -replace "import com.shinhan.mcp.common", "import io.shinhanlife.axhub.common.mcp"
|
||||
|
||||
$content = $content -replace "package com.shinhan.mcp", "package io.shinhanlife.axhub.biz.mcp"
|
||||
$content = $content -replace "import com.shinhan.mcp", "import io.shinhanlife.axhub.biz.mcp"
|
||||
|
||||
# Do NOT do wild regex replacements for service/util/etc.
|
||||
|
||||
[System.IO.File]::WriteAllText($_.FullName, $content, $utf8)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.aop;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class EimsMonitoringAspect {
|
||||
|
||||
// 포인트컷: com.shinhan.mcp.adapter.service 패키지 내의 EimsSender를 구현한 모든 클래스의 메서드를 타겟으로 지정합니다.
|
||||
@Around("execution(* com.shinhan.mcp.adapter.service.*EimsSender.*(..))")
|
||||
public Object monitorEimsCommunication(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
|
||||
// 1. 호출되는 클래스와 메서드 이름 추출
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
Object[] args = joinPoint.getArgs(); // 파라미터
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 2. [요청 로깅] EIMS망으로 요청이 나가기 직전
|
||||
log.info(" [EIMS 요청] {} - {}() | Params: {}", className, methodName, Arrays.toString(args));
|
||||
|
||||
try {
|
||||
// 실제 레거시 통신 로직 실행 (이 코드가 없으면 통신이 진행되지 않습니다)
|
||||
Object result = joinPoint.proceed();
|
||||
|
||||
// 3. [응답 로깅] 정상적으로 통신이 완료된 후
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.info("◀ [EIMS 응답] {} - {}() | 소요시간: {}ms | Result: {}", className, methodName, executionTime, result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
// 4. [에러 로깅] 레거시 통신 중 장애(타임아웃 등) 발생 시
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.error(" [EIMS 에러] {} - {}() | 소요시간: {}ms | Error: {}", className, methodName, executionTime, e.getMessage());
|
||||
|
||||
// 에러를 삼키지 않고 다시 던져서 기존 예외 처리 로직(서킷 브레이커 등)이 작동하게 합니다.
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.aop;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StopWatch;
|
||||
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class GatewayLoggingAspect {
|
||||
|
||||
// connector 패키지 하위의 모든 클래스/메서드 실행 시 작동
|
||||
@Around("execution(* com.shinhan.mcp.adapter.controller..*(..)) " +
|
||||
"|| execution(* com.shinhan.mcp.adapter.connector..*(..))")
|
||||
public Object logConnectorExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
String targetMethod = joinPoint.getSignature().toShortString();
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
|
||||
log.info(" [Gateway 송신 시작] Target: {}", targetMethod);
|
||||
stopWatch.start();
|
||||
|
||||
try {
|
||||
// 실제 대상 메서드(외부 통신) 실행
|
||||
Object result = joinPoint.proceed();
|
||||
return result;
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
long timeMillis = stopWatch.getTotalTimeMillis();
|
||||
|
||||
if (timeMillis > 3000) { // 3초 이상 걸리면 WARN 로그로 슬로우 통신 경고
|
||||
log.warn("⏱ [Gateway 슬로우 응답] Target: {} | 소요시간: {}ms", targetMethod, timeMillis);
|
||||
} else {
|
||||
log.info("⏹ [Gateway 송신 완료] Target: {} | 소요시간: {}ms", targetMethod, timeMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicPayloadBuilder;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicSchemaValidator;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ExternalApiConnector {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final DynamicSchemaValidator schemaValidator;
|
||||
private final DynamicPayloadBuilder payloadBuilder;
|
||||
|
||||
public ExternalApiConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
|
||||
this.restClient = RestClient.create();
|
||||
this.schemaValidator = schemaValidator;
|
||||
this.payloadBuilder = payloadBuilder;
|
||||
}
|
||||
|
||||
@RateLimiter(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
|
||||
@CircuitBreaker(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
|
||||
public String callExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
|
||||
|
||||
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
|
||||
StringBuilder errorLog = new StringBuilder();
|
||||
if (!schemaValidator.validate(spec, data, errorLog)) {
|
||||
String errorMsg = "API 요청 데이터 스키마 불일치 [" + apiName + "]: " + errorLog.toString();
|
||||
log.error(" {}", errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
|
||||
// 2. 페이로드 빌드
|
||||
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
|
||||
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
|
||||
|
||||
log.info("🌐 외부 API 호출 [{}] 시작 (FixedLength: {})", apiName, isFixedLength);
|
||||
|
||||
return restClient.post()
|
||||
.uri(endpoint)
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
}
|
||||
|
||||
public String fallbackForExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
|
||||
log.error(" [외부 API 장애] {} 호출 실패: {}", apiName, t.getMessage());
|
||||
return String.format("{\"status\":\"EXTERNAL_API_ERROR\", \"message\":\"외부 서비스 연동 중 오류 발생: %s\"}", t.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicPayloadBuilder;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicSchemaValidator;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InternalSystemConnector {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final DynamicSchemaValidator schemaValidator;
|
||||
private final DynamicPayloadBuilder payloadBuilder;
|
||||
|
||||
// 생성자를 통한 의존성 주입 (Spring이 알아서 Validator와 Builder를 넣어줍니다)
|
||||
public InternalSystemConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
|
||||
this.restClient = RestClient.create();
|
||||
this.schemaValidator = schemaValidator;
|
||||
this.payloadBuilder = payloadBuilder;
|
||||
}
|
||||
|
||||
@RateLimiter(name = "internalSystem", fallbackMethod = "fallbackForInternal")
|
||||
@CircuitBreaker(name = "internalSystem", fallbackMethod = "fallbackForInternal")
|
||||
public String callInternalSystem(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
|
||||
|
||||
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
|
||||
StringBuilder errorLog = new StringBuilder();
|
||||
if (!schemaValidator.validate(spec, data, errorLog)) {
|
||||
String errorMsg = "대내외 시스템 연계 데이터 스키마 불일치 [" + targetName + "]: " + errorLog.toString();
|
||||
log.error(" {}", errorMsg);
|
||||
throw new IllegalArgumentException(errorMsg);
|
||||
}
|
||||
|
||||
// 2. 페이로드 빌드 (고정장 vs JSON)
|
||||
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
|
||||
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
|
||||
|
||||
log.info(" 대내외 시스템 호출 [{}] 시작 (FixedLength: {})", targetName, isFixedLength);
|
||||
|
||||
return restClient.post()
|
||||
.uri(endpoint)
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
}
|
||||
|
||||
// 통신 장애(CircuitBreaker) 또는 허용량 초과(RateLimiter) 시 처리 로직
|
||||
public String fallbackForInternal(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
|
||||
log.error(" [대내외 시스템 장애/지연] {} 연계 실패: {}", targetName, t.getMessage());
|
||||
return String.format("{\"status\":\"INTERNAL_SYSTEM_ERROR\", \"message\":\"대내외 연계 시스템 호출 중 오류가 발생했거나 요청이 지연되었습니다. 사유: %s\"}", t.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.ResultStandardizer;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LegacyDbConnector {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbcTemplate; // 동적 파라미터 바인딩을 위한 템플릿
|
||||
private final ResultStandardizer resultStandardizer;
|
||||
|
||||
@RateLimiter(name = "legacyDb", fallbackMethod = "fallbackForDb")
|
||||
@CircuitBreaker(name = "legacyDb", fallbackMethod = "fallbackForDb")
|
||||
public List<Map<String, Object>> executeDynamicQuery(String queryId, String sql, Map<String, Object> params) {
|
||||
log.info("🗄 Legacy DB 조회 시작 [QueryID: {}]", queryId);
|
||||
|
||||
// 1. SQL 쿼리 실행 (오라클 등)
|
||||
List<Map<String, Object>> rawResults = jdbcTemplate.queryForList(sql, params);
|
||||
|
||||
// 2. 스키마 매핑 및 결과 정형화 (대문자 -> 카멜케이스 변환)
|
||||
List<Map<String, Object>> standardResults = rawResults.stream()
|
||||
.map(resultStandardizer::standardize)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info(" Legacy DB 조회 완료 ({}건 반환)", standardResults.size());
|
||||
return standardResults;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> fallbackForDb(String queryId, String sql, Map<String, Object> params, Throwable t) {
|
||||
log.error(" [Legacy DB 장애/지연] 쿼리 실행 실패 [{}]: {}", queryId, t.getMessage());
|
||||
throw new RuntimeException("레거시 DB 연동 중 오류가 발생했습니다. (QueryID: " + queryId + ")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.sender.EimsSender;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.util.LegacyDataTransformer;
|
||||
|
||||
@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 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(" MCP 요청 접수 - RoutingType: {}, Interface: {}", routingType, interfaceId);
|
||||
|
||||
// 2. 4단계 라우팅 분기 처리
|
||||
if ("HTTP".equalsIgnoreCase(routingType)) {
|
||||
log.info("[라우팅] EIMS API (HTTP) 통신으로 전달");
|
||||
return httpEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
else if ("TCP".equalsIgnoreCase(routingType)) {
|
||||
log.info("[라우팅] EIMS 소켓 (TCP) 통신으로 전달");
|
||||
return tcpEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
else if ("JSP_FORM".equalsIgnoreCase(routingType)) {
|
||||
log.info("[라우팅] JSP Form 통신으로 전달");
|
||||
return jspFormEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
else if ("JSP_JSON".equalsIgnoreCase(routingType)) {
|
||||
log.info("[라우팅] JSP JSON 통신으로 전달");
|
||||
return jspJsonEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
else {
|
||||
// 3. 아키텍처 규격에 맞춘 라우팅 (실시간 vs 비동기)
|
||||
if (isMciRouting(routingType, interfaceId)) {
|
||||
log.info("[라우팅] 실시간 AI 요청 -> MCI 연계 어댑터를 통해 EIMS 전달");
|
||||
return mciEimsSender.send(interfaceId, payload);
|
||||
} else {
|
||||
log.info("[라우팅] 비동기/대용량 요청 -> EAI 연계 어댑터를 통해 EIMS 전달");
|
||||
return eaiEimsSender.send(interfaceId, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 라우팅 조건을 판단하는 내부 메서드 (관리를 위해 분리)
|
||||
private boolean isMciRouting(String routingType, String interfaceId) {
|
||||
// AI 에이전트가 툴을 호출하는 경우는 대부분 즉각적인 대답이 필요한 '실시간 조회(MCI)'입니다.
|
||||
return "MCI".equalsIgnoreCase(routingType) || (interfaceId != null && interfaceId.startsWith("MCI"));
|
||||
}
|
||||
|
||||
|
||||
// 비상용 응답 메서드 (차단기가 열려있거나, 타임아웃/에러가 났을 때 실행됨)
|
||||
// 주의: 파라미터는 원본 메서드와 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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@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);
|
||||
|
||||
// 보안 모듈 통신을 위한 특수 페이로드 조립 (예시)
|
||||
// 실제로는 RestTemplate이나 WebClient를 통해 보안 VM의 전용 엔드포인트로 호출합니다.
|
||||
|
||||
String resultJson;
|
||||
|
||||
if (interfaceId.startsWith("DRM_")) {
|
||||
log.info(" [DRM 처리] 내부 문서 암/복호화 모듈과 통신 중...");
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "SUCCESS",
|
||||
"module", "DRM",
|
||||
"message", "문서 보안 처리가 완료되었습니다.",
|
||||
"interfaceId", interfaceId,
|
||||
"data", data != null ? data : Map.of()
|
||||
));
|
||||
} else if (interfaceId.startsWith("BM_")) {
|
||||
log.info("👆 [BM 처리] 바이오 인증 모듈과 통신 중...");
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "SUCCESS",
|
||||
"module", "Bio-Metric",
|
||||
"message", "바이오 인증이 완료되었습니다.",
|
||||
"interfaceId", interfaceId,
|
||||
"data", data != null ? data : Map.of()
|
||||
));
|
||||
} else {
|
||||
log.warn(" 알 수 없는 보안 모듈 연동 요청: {}", interfaceId);
|
||||
resultJson = jsonMapper.writeValueAsString(Map.of(
|
||||
"status", "UNKNOWN_MODULE",
|
||||
"message", "알 수 없는 보안 모듈 인터페이스입니다."
|
||||
));
|
||||
}
|
||||
|
||||
log.info(" [3rd Party Security] 보안 모듈 처리 완료");
|
||||
return resultJson;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// [MockEsbController.java] - 로컬 테스트용 가짜 ESB 서버
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
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;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/mock/esb")
|
||||
public class MockEsbController {
|
||||
|
||||
// MCI (RestClient)가 이 주소로 실제 HTTP 요청을 보냅니다.
|
||||
@PostMapping(value = "/api", consumes = MediaType.APPLICATION_XML_VALUE, produces = MediaType.APPLICATION_XML_VALUE)
|
||||
public String mockMciResponse(@RequestBody String requestXml) {
|
||||
log.info("[가짜 ESB 서버] MCI 실시간 요청 수신 완료!\n받은 데이터: {}", requestXml);
|
||||
|
||||
// ESB 서버가 응답하는 것처럼 가짜 XML 전문을 리턴합니다.
|
||||
return "<EsbMessage><Header><Status>SUCCESS</Status></Header><Body><Message>정상 조회되었습니다.</Message></Body></EsbMessage>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@AllArgsConstructor
|
||||
public class ErrorDetail {
|
||||
private int code;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class JsonRpcRequest {
|
||||
private String jsonrpc;
|
||||
private String method;
|
||||
private Params params;
|
||||
private String id;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
// 2. 응답 DTO
|
||||
@Getter
|
||||
@Setter
|
||||
public class JsonRpcResponse {
|
||||
public String jsonrpc = "2.0";
|
||||
public Object result;
|
||||
public Object error;
|
||||
public String id;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class Params {
|
||||
private String routingType;
|
||||
private String name;
|
||||
private String interfaceId;
|
||||
private Map<String, Object> data;
|
||||
private List<Map<String, Object>> spec;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.support.TicketManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
|
||||
@Slf4j
|
||||
@Service("eaiEimsSender")
|
||||
@RequiredArgsConstructor
|
||||
public class EaiEimsSender implements EimsSender {
|
||||
|
||||
private final ObjectMapper jsonMapper;
|
||||
private final XmlMapper xmlMapper;
|
||||
|
||||
// 실전 코드: 스프링이 제공하는 카프카 템플릿 주입
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
|
||||
// 🎫 비동기 티켓 매니저
|
||||
private final TicketManager ticketManager;
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String jsonPayload) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch(); stopWatch.start();
|
||||
|
||||
try {
|
||||
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
|
||||
String xmlData = xmlMapper.writeValueAsString(jsonNode);
|
||||
String esbStandardXml = wrapWithEaiHeader(interfaceId, xmlData);
|
||||
|
||||
log.info("📦 [EAI 어댑터] Kafka 토픽(eai-topic)으로 전송 시도...");
|
||||
|
||||
try {
|
||||
// 실전 코드 적용: Kafka로 메시지 발행
|
||||
kafkaTemplate.send("eai-topic", esbStandardXml);
|
||||
log.info("📦 [EAI 어댑터] Kafka 전송 완료!");
|
||||
} catch (Exception e) {
|
||||
// 로컬 환경에는 카프카가 없으므로 에러가 날 수 있습니다. 테스트를 위해 로깅만 하고 넘깁니다.
|
||||
log.warn(" 로컬 환경이거나 Ka<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
||||
"<EaiMessage>\n" +
|
||||
" <Header>\n" +
|
||||
" <ChannelId>MCP_GATEWAY_ASYNC</ChannelId>\n" +
|
||||
" <InterfaceId>EAI_BATCH_JOB</InterfaceId>\n" +
|
||||
" <Timestamp>1782705901850</Timestamp>\n" +
|
||||
" <TransferType>ASYNC</TransferType>\n" +
|
||||
" </Header>\n" +
|
||||
" <Body>\n" +
|
||||
" <ObjectNode>\n" +
|
||||
" <batchId>BATCH_20260629_001</batchId>\n" +
|
||||
" <targetSystem>GLOBAL_MINIMUM_TAX_SYS</targetSystem>\n" +
|
||||
" <recordCount>50000</recordCount>\n" +
|
||||
" </ObjectNode>\n" +
|
||||
" </Body>\n" +
|
||||
"</EaiMessage>fka 서버에 연결할 수 없습니다. (메시지 출력으로 대체합니다) \n전송하려던 메시지: {}", esbStandardXml);
|
||||
}
|
||||
|
||||
// 비동기 폴링을 위한 티켓 발급
|
||||
String ticketId = ticketManager.issueTicket(interfaceId, jsonPayload);
|
||||
|
||||
return String.format(
|
||||
"{\"status\":\"PROCESSING\", \"interfaceId\":\"%s\", \"ticketId\":\"%s\", \"message\":\"비동기 작업이 접수되었습니다. 상태 조회 API를 통해 결과를 확인하세요.\"}",
|
||||
interfaceId, ticketId);
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info("📊 [SLA 모니터링 - EAI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapWithEaiHeader(String interfaceId, String xmlData) {
|
||||
// 비동기 EAI는 추적을 위해 TransferType이나 Batch ID 같은 속성이 추가로 들어가는 경우가 많습니다.
|
||||
return String.format(
|
||||
"<EaiMessage>" +
|
||||
"<Header>" +
|
||||
"<ChannelId>MCP_GATEWAY_ASYNC</ChannelId>" +
|
||||
"<InterfaceId>%s</InterfaceId>" +
|
||||
"<Timestamp>%d</Timestamp>" +
|
||||
"<TransferType>ASYNC</TransferType>" +
|
||||
"</Header>" +
|
||||
"<Body>%s</Body>" +
|
||||
"</EaiMessage>",
|
||||
interfaceId, System.currentTimeMillis(), xmlData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
public interface EimsSender {
|
||||
// 프로토콜에 상관없이 이 메서드 하나로 통일합니다.
|
||||
String send(String interfaceId, String payload) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HttpEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String eimsUrl;
|
||||
|
||||
public HttpEimsSender(@Value("${eims.http.url}") String eimsUrl) {
|
||||
this.eimsUrl = eimsUrl;
|
||||
this.restClient = RestClient.builder().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 = org.slf4j.MDC.get("traceId");
|
||||
if (traceId == null) traceId = "SYSTEM-GENERATED-" + java.util.UUID.randomUUID().toString();
|
||||
|
||||
return restClient.post()
|
||||
.uri(eimsUrl)
|
||||
.header("X-Trace-Id", traceId)
|
||||
.header("X-Shinhan-Global-ID", traceId)
|
||||
.body(requestBody)
|
||||
.retrieve()
|
||||
.body(String.class); // 응답 결과를 String으로 받음
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JspFormEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String jspUrl;
|
||||
|
||||
public JspFormEimsSender(@Value("${eims.jsp.form.url}") String jspUrl) {
|
||||
this.jspUrl = jspUrl;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) {
|
||||
log.info("🌐 [JSP Form 모드] 레거시 폼 데이터 전송 중... URL: {}", jspUrl);
|
||||
|
||||
// 1. Form Data 조립 (HTML <form> 태그 전송과 동일한 효과)
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("interfaceId", interfaceId);
|
||||
formData.add("data", payload);
|
||||
|
||||
// 2. HTTP 전송 (Content-Type: application/x-www-form-urlencoded)
|
||||
String rawResponse = restClient.post()
|
||||
.uri(jspUrl)
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(formData)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
// 3. JSP 특유의 앞뒤 공백 및 엔터 제거
|
||||
return rawResponse != null ? rawResponse.trim() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JspJsonEimsSender implements EimsSender {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String jspUrl;
|
||||
|
||||
// 여기서 eims.jsp.json.url 딱 하나만 깔끔하게 받아옵니다!
|
||||
public JspJsonEimsSender(@Value("${eims.jsp.json.url}") String jspUrl) {
|
||||
this.jspUrl = jspUrl;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) {
|
||||
log.info("🌐 [JSP JSON 모드] JSON 페이로드 전송 중... URL: {}", jspUrl);
|
||||
|
||||
// 1. JSON 객체로 조립 (스프링이 알아서 JSON String으로 변환해 줌)
|
||||
Map<String, String> jsonBody = Map.of(
|
||||
"interfaceId", interfaceId,
|
||||
"data", payload
|
||||
);
|
||||
|
||||
// 2. HTTP 전송 (Content-Type: application/json)
|
||||
String rawResponse = restClient.post()
|
||||
.uri(jspUrl)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(jsonBody)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
// 3. JSP 응답 정제
|
||||
return rawResponse != null ? rawResponse.trim() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@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(); // 클라이언트 초기화
|
||||
}
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String jsonPayload) throws Exception {
|
||||
StopWatch stopWatch = new StopWatch(); stopWatch.start();
|
||||
|
||||
try {
|
||||
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
|
||||
String xmlData = xmlMapper.writer().withRootName("Body").writeValueAsString(jsonNode);
|
||||
String esbStandardXml = wrapWithEsbHeader(interfaceId, xmlData);
|
||||
|
||||
log.info("🌐 [ESB 어댑터] 전송 준비 완료 - RestClient 호출 시작");
|
||||
|
||||
String responseXml = restClient.post()
|
||||
.uri(mciUrl)
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(esbStandardXml)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
log.info("🌐 [ESB 어댑터] 응답 수신 완료: {}", responseXml);
|
||||
|
||||
JsonNode responseNode = xmlMapper.readTree(responseXml);
|
||||
return jsonMapper.writeValueAsString(responseNode);
|
||||
|
||||
} finally {
|
||||
stopWatch.stop();
|
||||
log.info("📊 [SLA 모니터링 - MCI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapWithEsbHeader(String interfaceId, String xmlData) {
|
||||
return String.format("<EsbMessage><Header><InterfaceId>%s</InterfaceId></Header><Body>%s</Body></EsbMessage>", interfaceId, xmlData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TcpEimsSender implements EimsSender {
|
||||
|
||||
@Value("${eims.tcp.host}")
|
||||
private String host;
|
||||
|
||||
@Value("${eims.tcp.port}")
|
||||
private int port;
|
||||
|
||||
@Value("${eims.tcp.timeout}")
|
||||
private int timeout;
|
||||
|
||||
@Override
|
||||
public String send(String interfaceId, String payload) throws Exception {
|
||||
log.info("🔌 [TCP 소켓 모드] EIMS 접속 중... {}:{}", host, port);
|
||||
|
||||
// TCP 소켓 자원을 사용 후 안전하게 닫아주는 try-with-resources 구문
|
||||
try (Socket socket = new Socket()) {
|
||||
// 1. 타임아웃 및 연결 설정
|
||||
socket.connect(new InetSocketAddress(host, port), timeout);
|
||||
socket.setSoTimeout(timeout); // 읽기 타임아웃
|
||||
|
||||
OutputStream os = socket.getOutputStream();
|
||||
InputStream is = socket.getInputStream();
|
||||
|
||||
// 2. 데이터 송신 (EUC-KR 인코딩 필수)
|
||||
// 보통 금융권 TCP 통신은 맨 앞에 전체 길이나 인터페이스 ID를 헤더로 붙입니다.
|
||||
String sendData = interfaceId + payload;
|
||||
os.write(sendData.getBytes("EUC-KR"));
|
||||
os.flush();
|
||||
|
||||
// 3. 데이터 수신
|
||||
byte[] buffer = new byte[4096];
|
||||
int readByte = is.read(buffer);
|
||||
|
||||
if (readByte == -1) {
|
||||
throw new RuntimeException("EIMS 서버가 응답 없이 연결을 종료했습니다.");
|
||||
}
|
||||
|
||||
// 받은 바이트를 다시 한글(EUC-KR) 문자열로 복원
|
||||
return new String(buffer, 0, readByte, "EUC-KR");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DynamicPayloadBuilder {
|
||||
|
||||
public String buildFixedLengthString(List<Map<String, Object>> specList, Map<String, Object> data) throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (Map<String, Object> spec : specList) {
|
||||
String name = (String) spec.get("name");
|
||||
// EIMS가 고정장을 요구할 경우를 대비한 len 파라미터 체크 (기본값 0 방어)
|
||||
int len = spec.get("len") != null ? (Integer) spec.get("len") : 0;
|
||||
String type = (String) spec.get("type");
|
||||
String rawValue = String.valueOf(data.getOrDefault(name, ""));
|
||||
|
||||
// len 값이 없으면 변환 없이 바로 이어붙임 (JSON 통신용)
|
||||
if (len == 0) {
|
||||
sb.append(rawValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
// len 값이 있으면 고정장 통신 규칙 적용
|
||||
byte[] rawBytes = rawValue.getBytes("EUC-KR");
|
||||
if (rawBytes.length > len) {
|
||||
throw new IllegalArgumentException(name + " 길이가 " + len + " 바이트를 초과합니다.");
|
||||
}
|
||||
|
||||
int padLength = len - rawBytes.length;
|
||||
StringBuilder paddedValue = new StringBuilder(rawValue);
|
||||
|
||||
if ("NUMBER".equalsIgnoreCase(type)) {
|
||||
for (int i = 0; i < padLength; i++) paddedValue.insert(0, "0");
|
||||
} else {
|
||||
for (int i = 0; i < padLength; i++) paddedValue.append(" ");
|
||||
}
|
||||
sb.append(paddedValue.toString());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DynamicSchemaValidator {
|
||||
|
||||
public boolean validate(List<Map<String, Object>> specList, Map<String, Object> data, StringBuilder errorLog) {
|
||||
for (Map<String, Object> spec : specList) {
|
||||
String name = (String) spec.get("name");
|
||||
String type = (String) spec.get("type");
|
||||
boolean isRequired = spec.get("required") != null && (Boolean) spec.get("required");
|
||||
Object value = data.get(name);
|
||||
|
||||
// 1. 필수값 체크
|
||||
if (isRequired && (value == null || String.valueOf(value).trim().isEmpty())) {
|
||||
errorLog.append(String.format("[%s] 필드는 필수 입력 항목입니다. ", name));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 타입 체크 (값이 있을 때만)
|
||||
if (value != null && !String.valueOf(value).trim().isEmpty()) {
|
||||
if ("NUMBER".equalsIgnoreCase(type) && !String.valueOf(value).matches("-?\\d+(\\.\\d+)?")) {
|
||||
errorLog.append(String.format("[%s] 필드는 숫자여야 합니다. ", name));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ResultStandardizer {
|
||||
|
||||
// 오라클 조회 결과(Map)의 키 값을 카멜 케이스로 변환 (스키마 매핑)
|
||||
public Map<String, Object> standardize(Map<String, Object> rawData) {
|
||||
Map<String, Object> standardMap = new HashMap<>();
|
||||
|
||||
rawData.forEach((key, value) -> {
|
||||
String camelKey = convertToCamelCase(key);
|
||||
standardMap.put(camelKey, value);
|
||||
});
|
||||
|
||||
return standardMap;
|
||||
}
|
||||
|
||||
private String convertToCamelCase(String snakeCase) {
|
||||
if (snakeCase == null || snakeCase.isEmpty()) return snakeCase;
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean nextIsUpper = false;
|
||||
|
||||
for (char c : snakeCase.toLowerCase().toCharArray()) {
|
||||
if (c == '_') {
|
||||
nextIsUpper = true;
|
||||
} else {
|
||||
result.append(nextIsUpper ? Character.toUpperCase(c) : c);
|
||||
nextIsUpper = false;
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.support;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TicketManager {
|
||||
|
||||
// 로컬 시뮬레이션을 위한 인메모리 저장소 (운영 환경에서는 Redis 등을 사용)
|
||||
private final Map<String, Ticket> ticketStore = new ConcurrentHashMap<>();
|
||||
|
||||
// 가짜 EAI 작업(10초 대기)을 실행할 백그라운드 쓰레드 풀
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
|
||||
|
||||
/**
|
||||
* 새로운 비동기 작업을 접수하고 티켓을 발급합니다.
|
||||
*/
|
||||
public String issueTicket(String interfaceId, String payload) {
|
||||
String ticketId = "TICKET-" + UUID.randomUUID().toString();
|
||||
|
||||
Ticket ticket = new Ticket();
|
||||
ticket.setTicketId(ticketId);
|
||||
ticket.setStatus("PROCESSING");
|
||||
ticket.setMessage("EAI 시스템에서 데이터 처리 중입니다...");
|
||||
|
||||
ticketStore.put(ticketId, ticket);
|
||||
|
||||
log.info("🎫 [TicketManager] 비동기 작업 티켓 발급 완료: {}", ticketId);
|
||||
|
||||
// 10초 뒤에 자동으로 작업을 완료 상태로 변경하는 백그라운드 시뮬레이터 실행
|
||||
simulateEaiProcessing(ticketId, interfaceId);
|
||||
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 티켓의 현재 상태를 조회합니다.
|
||||
*/
|
||||
public Ticket getTicketStatus(String ticketId) {
|
||||
return ticketStore.getOrDefault(ticketId, new Ticket("NOT_FOUND", "해당 티켓을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 10초 뒤에 상태를 COMPLETED로 변경하여 진짜 EAI 배치가 끝난 것처럼 흉내냅니다.
|
||||
*/
|
||||
private void simulateEaiProcessing(String ticketId, String interfaceId) {
|
||||
scheduler.schedule(() -> {
|
||||
Ticket ticket = ticketStore.get(ticketId);
|
||||
if (ticket != null) {
|
||||
ticket.setStatus("COMPLETED");
|
||||
ticket.setMessage("EAI 배치가 정상적으로 완료되었습니다.");
|
||||
// 가짜 최종 결과 데이터 주입
|
||||
ticket.setResultData("{\"resultCode\":\"0000\", \"interfaceId\":\"" + interfaceId + "\", \"processedRecords\":50000}");
|
||||
ticketStore.put(ticketId, ticket);
|
||||
log.info("🏁 [TicketManager] EAI 비동기 작업 시뮬레이션 완료! (Ticket: {})", ticketId);
|
||||
}
|
||||
}, 10, TimeUnit.SECONDS); // 10초 지연
|
||||
}
|
||||
|
||||
// 티켓 상태를 담을 내부 DTO 클래스
|
||||
@lombok.Data
|
||||
public static class Ticket {
|
||||
private String ticketId;
|
||||
private String status; // PROCESSING, COMPLETED, FAILED, NOT_FOUND
|
||||
private String message;
|
||||
private String resultData; // 최종 완료 시 담길 데이터
|
||||
|
||||
public Ticket() {}
|
||||
public Ticket(String status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.support;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.ErrorDetail;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcRequest;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ToolExecutionService {
|
||||
|
||||
// 방어막(서킷/캐시)이 적용된 커넥터 주입
|
||||
private final LegacyEimsConnector legacyEimsConnector;
|
||||
|
||||
/**
|
||||
* AI Agent가 호출한 툴을 실제 레거시 커넥터로 전달합니다.
|
||||
*/
|
||||
public JsonRpcResponse executeTool(JsonRpcRequest request) {
|
||||
Params params = request.getParams();
|
||||
|
||||
// 1. 필수 파라미터 검증
|
||||
if (params == null || params.getName() == null) {
|
||||
return createErrorResponse(request.getId(), -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Connector의 executeByTool 메서드 호출
|
||||
// [참고] connector에 선언된 파라미터 구조에 맞게 매핑합니다.
|
||||
String result = legacyEimsConnector.executeByTool(
|
||||
params.getRoutingType(),
|
||||
params.getInterfaceId(),
|
||||
params.getData(),
|
||||
params.getSpec()
|
||||
);
|
||||
|
||||
// 3. 성공 응답 생성 (result가 JSON 문자열일 경우, 실제 DTO로 변환하여 반환하면 더 좋습니다)
|
||||
return createSuccessResponse(request.getId(), result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error(" [ToolExecution] 커넥터 호출 실패: RoutingType={}, Error={}", params.getRoutingType(), e.getMessage());
|
||||
return createErrorResponse(request.getId(), -32000, "Connector error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Agent를 위한 툴 목록 스키마 반환
|
||||
*/
|
||||
public JsonRpcResponse getToolList(String requestId) {
|
||||
// 기존에 정의한 Tool 스키마 리스트 반환 로직...
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// ... (Tool 명세 내용)
|
||||
return createSuccessResponse(requestId, result);
|
||||
}
|
||||
|
||||
private JsonRpcResponse createSuccessResponse(String id, Object result) {
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(id);
|
||||
response.setResult(result);
|
||||
return response;
|
||||
}
|
||||
|
||||
private JsonRpcResponse createErrorResponse(String id, int code, String message) {
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(id);
|
||||
response.setError(new ErrorDetail(code, message));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.test;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import java.util.Map;
|
||||
|
||||
@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>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.test;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
// 대신 "local" 환경(application-local.properties)에서만 가짜 소켓 서버가 켜지도록 보장합니다.
|
||||
@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() {
|
||||
if (serverPort != 8081) {
|
||||
log.info(" [가짜 EIMS 서버] Gateway 포트(8081)가 아니므로 EIMS TCP 서버를 중복해서 띄우지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Java 21 가상 스레드를 사용하여 메인 서버 가동에 방해 없이 백그라운드에서 실행
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
serverSocket = new ServerSocket(port);
|
||||
log.info(" [가짜 EIMS 서버] 로컬 TCP 소켓 서버 가동 완료 (Port: {})", port);
|
||||
|
||||
while (running) {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
|
||||
// 연결된 요청을 별도 스레드로 처리
|
||||
Thread.ofVirtual().start(() -> handleClient(clientSocket));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (running) log.error(" 가짜 TCP 서버 에러: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleClient(Socket socket) {
|
||||
try (socket) {
|
||||
InputStream is = socket.getInputStream();
|
||||
OutputStream os = socket.getOutputStream();
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int readByte = is.read(buffer);
|
||||
|
||||
if (readByte != -1) {
|
||||
String receivedData = new String(buffer, 0, readByte, "EUC-KR");
|
||||
log.info(" [가짜 EIMS 서버] TCP 데이터 수신 완료!");
|
||||
log.info(" 수신된 전문: [{}]", receivedData);
|
||||
|
||||
String responseData = "{\"status\":\"404\",\"message\":\"MOCK 데이터가 정의되지 않았습니다.\"}";
|
||||
try {
|
||||
int braceIndex = receivedData.indexOf('{');
|
||||
String interfaceId = "";
|
||||
if (braceIndex > 0) {
|
||||
interfaceId = receivedData.substring(0, braceIndex).trim();
|
||||
} else if (braceIndex == -1) {
|
||||
interfaceId = receivedData.trim();
|
||||
}
|
||||
|
||||
ClassPathResource resource = new ClassPathResource("mock-responses.json");
|
||||
Map<String, Object> mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class);
|
||||
|
||||
if (mockDataMap.containsKey(interfaceId)) {
|
||||
responseData = objectMapper.writeValueAsString(mockDataMap.get(interfaceId));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("TCP JSON 파싱 에러 또는 파일 읽기 실패", e);
|
||||
}
|
||||
|
||||
// 응답 데이터 송신
|
||||
os.write(responseData.getBytes("EUC-KR"));
|
||||
os.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(" 가짜 TCP 클라이언트 처리 에러: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stopTcpServer() {
|
||||
this.running = false;
|
||||
try {
|
||||
if (serverSocket != null) serverSocket.close();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.test;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/mock")
|
||||
public class MockJspServer {
|
||||
|
||||
// ==========================================
|
||||
// 1. Form Data 방식 테스트 수신부 (jsp-form)
|
||||
// ==========================================
|
||||
@PostMapping(value = "/jsp-form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
public String mockJspFormReceiver(
|
||||
@RequestParam("interfaceId") String interfaceId,
|
||||
@RequestParam("data") String data) {
|
||||
|
||||
log.info(" [가짜 JSP 서버] 폼 데이터(Form) 수신 완료!");
|
||||
log.info(" 파라미터 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
|
||||
|
||||
// 실제 JSP 서버처럼 앞뒤에 의미 없는 줄바꿈(엔터)과 공백을 잔뜩 넣어서 리턴합니다.
|
||||
return "\n\n SUCCESS_FROM_MOCK_JSP_FORM \n\n";
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. JSON 방식 테스트 수신부 (jsp-json)
|
||||
// ==========================================
|
||||
@PostMapping(value = "/jsp-json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public String mockJspJsonReceiver(@RequestBody Map<String, String> request) {
|
||||
|
||||
String interfaceId = request.get("interfaceId");
|
||||
String data = request.get("data");
|
||||
|
||||
log.info(" [가짜 JSP 서버] 제이슨(JSON) 수신 완료!");
|
||||
log.info(" JSON 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
|
||||
|
||||
// 여기도 마찬가지로 쓰레기 여백을 넣어줍니다.
|
||||
return "\n\n SUCCESS_FROM_MOCK_JSP_JSON \n\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class LegacyDataTransformer {
|
||||
|
||||
/**
|
||||
* AI 모델이 보낸 자유로운 형태의 data를 레거시 시스템 규격(spec)에 맞게 강제 변환합니다.
|
||||
*
|
||||
* @param data AI가 보낸 파라미터 맵
|
||||
* @param spec 레거시 시스템이 요구하는 파라미터 스펙 (name, type, maxLength, defaultValue, required 등)
|
||||
* @return 엄격하게 정제된 파라미터 맵
|
||||
*/
|
||||
public static Map<String, Object> transform(Map<String, Object> data, List<Map<String, Object>> spec) {
|
||||
if (spec == null || spec.isEmpty()) {
|
||||
return data != null ? data : new HashMap<>();
|
||||
}
|
||||
|
||||
Map<String, Object> transformedData = new HashMap<>();
|
||||
|
||||
for (Map<String, Object> fieldSpec : spec) {
|
||||
String fieldName = (String) fieldSpec.get("name");
|
||||
if (fieldName == null) continue;
|
||||
|
||||
Object rawValue = data != null ? data.get(fieldName) : null;
|
||||
Object finalValue = rawValue;
|
||||
|
||||
// 1. 기본값(Default Value) 주입
|
||||
if (finalValue == null && fieldSpec.containsKey("defaultValue")) {
|
||||
finalValue = fieldSpec.get("defaultValue");
|
||||
log.debug(" [DataTransformer] '{}' 필드 누락 -> 기본값 '{}' 주입", fieldName, finalValue);
|
||||
}
|
||||
|
||||
// 2. 강제 형변환 (Type Coercion)
|
||||
String type = (String) fieldSpec.getOrDefault("type", "string");
|
||||
if (finalValue != null) {
|
||||
if ("string".equalsIgnoreCase(type) && !(finalValue instanceof String)) {
|
||||
finalValue = String.valueOf(finalValue);
|
||||
log.debug(" [DataTransformer] '{}' 필드 강제 String 형변환", fieldName);
|
||||
} else if ("number".equalsIgnoreCase(type) && finalValue instanceof String) {
|
||||
try {
|
||||
finalValue = Long.parseLong((String) finalValue);
|
||||
log.debug(" [DataTransformer] '{}' 필드 강제 Number 형변환", fieldName);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn(" [DataTransformer] '{}' 필드 Number 형변환 실패. 기존 값 유지", fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 길이 제한 (Truncation / MaxLength)
|
||||
if (finalValue instanceof String && fieldSpec.containsKey("maxLength")) {
|
||||
int maxLength = (Integer) fieldSpec.get("maxLength");
|
||||
String strVal = (String) finalValue;
|
||||
if (strVal.length() > maxLength) {
|
||||
finalValue = strVal.substring(0, maxLength);
|
||||
log.warn(" [DataTransformer] '{}' 필드 길이 초과! {}자로 강제 자름 (Truncated)", fieldName, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 필수값(Required) 누락 체크 (에러를 던지지 않고 빈 문자열 강제 주입하여 레거시 팅김 방지)
|
||||
boolean isRequired = (Boolean) fieldSpec.getOrDefault("required", false);
|
||||
if (isRequired && finalValue == null) {
|
||||
log.error(" [DataTransformer] 필수 필드 '{}' 누락! 강제 공백 주입하여 시스템 장애 방지", fieldName);
|
||||
finalValue = "string".equalsIgnoreCase(type) ? "" : 0;
|
||||
}
|
||||
|
||||
if (finalValue != null) {
|
||||
transformedData.put(fieldName, finalValue);
|
||||
}
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.util;
|
||||
|
||||
import ch.qos.logback.classic.pattern.MessageConverter;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
|
||||
/**
|
||||
* Logback 커스텀 컨버터
|
||||
* 모든 로그 메시지(%msg)가 파일이나 콘솔에 찍히기 직전에 이 클래스를 거쳐가게 됩니다.
|
||||
* 여기서 PiiMaskingUtils.mask()를 호출하여 PII(주민번호, 계좌번호 등)를 안전하게 별표(*) 처리합니다.
|
||||
*/
|
||||
public class PiiMaskingLogbackConverter extends MessageConverter {
|
||||
|
||||
@Override
|
||||
public String convert(ILoggingEvent event) {
|
||||
// 원본 로그 메시지를 가져옵니다.
|
||||
String originalMessage = super.convert(event);
|
||||
|
||||
// 정규식을 이용하여 개인정보가 포함되어 있으면 마스킹 처리하여 반환합니다.
|
||||
return PiiMaskingUtils.mask(originalMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.adapter.util;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class PiiMaskingUtils {
|
||||
|
||||
// 1. 주민등록번호 패턴 (ex: 900101-1234567 또는 9001011234567)
|
||||
private static final Pattern RRN_PATTERN = Pattern.compile("(\\d{6})[-]?([1-4]\\d{6})");
|
||||
|
||||
// 2. 휴대전화번호 패턴 (ex: 010-1234-5678)
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("(01[016789])[-]?(\\d{3,4})[-]?(\\d{4})");
|
||||
|
||||
// 3. 신한은행 계좌번호 패턴 (단순 숫자 연속 또는 하이픈 조합으로 11~14자리)
|
||||
private static final Pattern ACCOUNT_PATTERN = Pattern.compile("(\\d{3})[-]?(\\d{3})[-]?(\\d{5,8})");
|
||||
|
||||
public static String mask(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
|
||||
String masked = input;
|
||||
|
||||
// [1] 주민번호 뒷자리 마스킹 (첫자리 성별 식별자는 남기고 마스킹: 900101-1******)
|
||||
Matcher rrnMatcher = RRN_PATTERN.matcher(masked);
|
||||
StringBuffer rrnBuffer = new StringBuffer();
|
||||
while (rrnMatcher.find()) {
|
||||
String firstPart = rrnMatcher.group(1);
|
||||
String secondPart = rrnMatcher.group(2);
|
||||
rrnMatcher.appendReplacement(rrnBuffer, firstPart + "-" + secondPart.charAt(0) + "******");
|
||||
}
|
||||
rrnMatcher.appendTail(rrnBuffer);
|
||||
masked = rrnBuffer.toString();
|
||||
|
||||
// [2] 전화번호 중간자리 마스킹 (010-****-5678)
|
||||
Matcher phoneMatcher = PHONE_PATTERN.matcher(masked);
|
||||
StringBuffer phoneBuffer = new StringBuffer();
|
||||
while (phoneMatcher.find()) {
|
||||
String p1 = phoneMatcher.group(1);
|
||||
String p2 = phoneMatcher.group(2);
|
||||
String p3 = phoneMatcher.group(3);
|
||||
String maskedP2 = p2.replaceAll(".", "*");
|
||||
phoneMatcher.appendReplacement(phoneBuffer, p1 + "-" + maskedP2 + "-" + p3);
|
||||
}
|
||||
phoneMatcher.appendTail(phoneBuffer);
|
||||
masked = phoneBuffer.toString();
|
||||
|
||||
// [3] 계좌번호 뒷자리 마스킹 (110-123-********)
|
||||
Matcher accMatcher = ACCOUNT_PATTERN.matcher(masked);
|
||||
StringBuffer accBuffer = new StringBuffer();
|
||||
while (accMatcher.find()) {
|
||||
String a1 = accMatcher.group(1);
|
||||
String a2 = accMatcher.group(2);
|
||||
String a3 = accMatcher.group(3);
|
||||
String maskedA3 = a3.replaceAll(".", "*");
|
||||
accMatcher.appendReplacement(accBuffer, a1 + "-" + a2 + "-" + maskedA3);
|
||||
}
|
||||
accMatcher.appendTail(accBuffer);
|
||||
masked = accBuffer.toString();
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.ThirdPartySecurityConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcRequest;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.util.PiiMaskingUtils;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.axhub.common.mcp.security.SecurityProperties;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.messaging.KafkaProducerService;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolClient {
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
private final CircuitBreakerRegistry circuitBreakerRegistry;
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
private final RetryRegistry retryRegistry;
|
||||
private final KafkaProducerService kafkaProducerService;
|
||||
private final RestClient restClient = RestClient.builder().build();
|
||||
|
||||
private final LegacyEimsConnector legacyEimsConnector;
|
||||
private final ThirdPartySecurityConnector thirdPartySecurityConnector;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 실제 Tool Pod를 호출합니다. (동적 서킷 브레이커 및 속도 제어 적용)
|
||||
*/
|
||||
public Object call(Object targetPod, Map<String, Object> payload, Object planObj) {
|
||||
log.info("[ToolClient] RPC 통신 준비 - 동적 제어 정책 적용");
|
||||
|
||||
ToolMetadata plan = (ToolMetadata) planObj;
|
||||
String toolName = plan.getToolName();
|
||||
|
||||
int failureRate = plan.getFailureRateThreshold() != null ? plan.getFailureRateThreshold() : 50;
|
||||
int slidingWindowSize = plan.getSlidingWindowSize() != null ? plan.getSlidingWindowSize() : 10;
|
||||
|
||||
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(failureRate)
|
||||
.slidingWindowSize(slidingWindowSize)
|
||||
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
|
||||
.waitDurationInOpenState(Duration.ofSeconds(10))
|
||||
.build();
|
||||
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(toolName, cbConfig);
|
||||
|
||||
int limitForPeriod = plan.getRateLimitForPeriod() != null ? plan.getRateLimitForPeriod() : 50;
|
||||
|
||||
RateLimiterConfig rlConfig = RateLimiterConfig.custom()
|
||||
.limitForPeriod(limitForPeriod)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ofMillis(0))
|
||||
.build();
|
||||
RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter(toolName, rlConfig);
|
||||
|
||||
RetryConfig rtConfig = RetryConfig.custom()
|
||||
.maxAttempts(3)
|
||||
.waitDuration(Duration.ofMillis(500))
|
||||
.build();
|
||||
Retry retry = retryRegistry.retry(toolName, rtConfig);
|
||||
|
||||
String internalApiKey = securityProperties.getApiKeys().keySet().iterator().next();
|
||||
|
||||
try {
|
||||
Supplier<Map<String, Object>> supplier = () -> {
|
||||
if (plan.getEndpoint() != null) {
|
||||
Map<String, Object> targetMap = (Map<String, Object>) targetPod;
|
||||
String baseUrl = (String) targetMap.get("targetUrl");
|
||||
String targetUrl = baseUrl + plan.getEndpoint();
|
||||
log.info("[ToolClient] 동적 엔드포인트 호출: {}", targetUrl);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-KEY", internalApiKey);
|
||||
|
||||
return restClient.post()
|
||||
.uri(targetUrl)
|
||||
.headers(h -> h.addAll(headers))
|
||||
.body(payload)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<Map<String, Object>>() {});
|
||||
} else {
|
||||
// 레거시 하드코딩 라우팅 (Adapter 로컬 직접 호출)
|
||||
log.info("[ToolClient] 레거시 라우팅: Adapter 로컬 모듈 호출");
|
||||
try {
|
||||
JsonRpcRequest request = objectMapper.convertValue(payload, JsonRpcRequest.class);
|
||||
Params params = request.getParams();
|
||||
|
||||
if (params == null) {
|
||||
throw new IllegalArgumentException("Invalid params");
|
||||
}
|
||||
|
||||
String routingType = params.getRoutingType();
|
||||
String interfaceId = params.getInterfaceId();
|
||||
|
||||
String executionResult;
|
||||
if (interfaceId != null && (interfaceId.startsWith("DRM_") || interfaceId.startsWith("BM_"))) {
|
||||
executionResult = thirdPartySecurityConnector.executeSecurityModule(interfaceId, params.getData());
|
||||
} else {
|
||||
executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, params.getData(), params.getSpec());
|
||||
}
|
||||
|
||||
String maskedResult = PiiMaskingUtils.mask(executionResult);
|
||||
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(request.getId() != null ? request.getId() : UUID.randomUUID().toString());
|
||||
response.setResult(maskedResult);
|
||||
|
||||
return objectMapper.convertValue(response, Map.class);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Adapter 로컬 호출 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Supplier<Map<String, Object>> rateLimitedSupplier = RateLimiter.decorateSupplier(rateLimiter, supplier);
|
||||
Supplier<Map<String, Object>> retryingSupplier = Retry.decorateSupplier(retry, rateLimitedSupplier);
|
||||
Supplier<Map<String, Object>> protectedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, retryingSupplier);
|
||||
|
||||
Map<String, Object> response = protectedSupplier.get();
|
||||
|
||||
log.info(" [ToolClient] RPC 호출 성공 (적용된 제어기: {})", toolName);
|
||||
return response;
|
||||
|
||||
} catch (RequestNotPermitted e) {
|
||||
log.warn(" [ToolClient] 트래픽 폭주 감지! Kafka 대기열로 요청을 전환합니다. (Tool: {})", toolName);
|
||||
|
||||
String ticketId = kafkaProducerService.queueRequest("mcp.tool.execute.queue", payload);
|
||||
|
||||
return Map.of(
|
||||
"status", "QUEUED",
|
||||
"ticketId", ticketId,
|
||||
"message", "현재 트래픽 폭주로 인해 대기열에 등록되었습니다. 티켓 ID를 통해 결과를 확인해주세요."
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error(" [ToolClient] RPC 통신 실패 또는 차단됨: {}", e.getMessage());
|
||||
throw new RuntimeException("Tool Pod 호출 중 예외/차단 발생: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.config;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, ToolMetadata> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, ToolMetadata> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// 1. Key는 무조건 String
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음)
|
||||
Jackson2JsonRedisSerializer<ToolMetadata> serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class);
|
||||
template.setValueSerializer(serializer);
|
||||
template.setHashValueSerializer(serializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.controller;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.service.KillSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/mcp/api/v1/admin/kill-switch")
|
||||
@RequiredArgsConstructor
|
||||
public class KillSwitchController {
|
||||
|
||||
private final KillSwitchService killSwitchService;
|
||||
|
||||
/**
|
||||
* 관리자가 비상 차단(Kill Switch) 상태를 토글합니다.
|
||||
* @param type "agent", "tool", "route" 중 하나
|
||||
* @param target 차단할 대상의 ID 또는 이름
|
||||
* @param state true(차단), false(차단 해제)
|
||||
*/
|
||||
@PostMapping("/{type}/{target}")
|
||||
public ResponseEntity<?> toggleKillSwitch(
|
||||
@PathVariable String type,
|
||||
@PathVariable String target,
|
||||
@RequestParam boolean state) {
|
||||
|
||||
killSwitchService.toggleKillSwitch(type, target, state);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Kill Switch 상태 변경 완료",
|
||||
"type", type,
|
||||
"target", target,
|
||||
"state", state
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자가 비상 차단(Kill Switch) 키를 Redis에서 완전히 삭제합니다.
|
||||
*/
|
||||
@DeleteMapping("/{type}/{target}")
|
||||
public ResponseEntity<?> removeKillSwitch(
|
||||
@PathVariable String type,
|
||||
@PathVariable String target) {
|
||||
|
||||
killSwitchService.removeKillSwitch(type, target);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Kill Switch 키 삭제 완료 (차단 해제)",
|
||||
"type", type,
|
||||
"target", target
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.ThirdPartySecurityConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcRequest;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.util.PiiMaskingUtils;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.service.ExecuteService;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.axhub.common.mcp.security.SecurityProperties;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/mcp/api/v1")
|
||||
@Tag(name = "MCP Router API", description = "AI Agent의 요청을 받아 Adapter 시스템으로 라우팅하는 게이트웨이 API")
|
||||
public class McpRouterController {
|
||||
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final ExecuteService executeService;
|
||||
private final SecurityProperties securityProperties;
|
||||
private final LegacyEimsConnector legacyEimsConnector;
|
||||
private final ThirdPartySecurityConnector thirdPartySecurityConnector;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// 생성자 주입
|
||||
public McpRouterController(RedisRegistryService redisRegistryService,
|
||||
ExecuteService executeService,
|
||||
SecurityProperties securityProperties,
|
||||
LegacyEimsConnector legacyEimsConnector,
|
||||
ThirdPartySecurityConnector thirdPartySecurityConnector,
|
||||
ObjectMapper objectMapper) {
|
||||
this.redisRegistryService = redisRegistryService;
|
||||
this.executeService = executeService;
|
||||
this.securityProperties = securityProperties;
|
||||
this.legacyEimsConnector = legacyEimsConnector;
|
||||
this.thirdPartySecurityConnector = thirdPartySecurityConnector;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Operation(summary = "Adapter 툴 실행 라우팅 (로컬 모듈화)", description = "AI Agent의 요청을 받아 직접 레거시 시스템을 호출합니다.")
|
||||
@PostMapping("/execute-tool")
|
||||
public ResponseEntity<Map<String, Object>> routeToPreBuildTool(@RequestBody Map<String, Object> agentPayload) {
|
||||
String trackId = UUID.randomUUID().toString();
|
||||
log.info("[{}] AI Agent로부터 툴 실행 요청 수신 완료", trackId);
|
||||
log.info("[{}] AI Agent가 보낸 원본 payload: {}", trackId, agentPayload);
|
||||
|
||||
try {
|
||||
// 1. 요청 파싱 (Map -> JsonRpcRequest)
|
||||
agentPayload.put("id", trackId);
|
||||
JsonRpcRequest request = objectMapper.convertValue(agentPayload, JsonRpcRequest.class);
|
||||
Params params = request.getParams();
|
||||
|
||||
if (params == null) {
|
||||
throw new IllegalArgumentException("Invalid params: params 객체가 비어있습니다.");
|
||||
}
|
||||
|
||||
String routingType = params.getRoutingType();
|
||||
String interfaceId = params.getInterfaceId();
|
||||
|
||||
// 2. 라우터 실행 (분기 처리) - HTTP 통신 없이 직접 호출!
|
||||
String executionResult;
|
||||
if (interfaceId != null && (interfaceId.startsWith("DRM_") || interfaceId.startsWith("BM_"))) {
|
||||
executionResult = thirdPartySecurityConnector.executeSecurityModule(interfaceId, params.getData());
|
||||
} else {
|
||||
executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, params.getData(), params.getSpec());
|
||||
}
|
||||
|
||||
// 3. PII 마스킹
|
||||
String maskedResult = PiiMaskingUtils.mask(executionResult);
|
||||
|
||||
// 4. 응답 조립 (JsonRpcResponse -> Map)
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(trackId);
|
||||
response.setResult(maskedResult);
|
||||
|
||||
Map<String, Object> rpcResponse = objectMapper.convertValue(response, Map.class);
|
||||
log.info("[{}] 로컬 Adapter 처리 성공", trackId);
|
||||
|
||||
return ResponseEntity.ok(rpcResponse);
|
||||
|
||||
} catch (ResourceAccessException e) {
|
||||
log.error("[{}] Adapter 시스템 응답 시간 초과 (Timeout) 발생! 사유: {}", trackId, e.getMessage());
|
||||
return ResponseEntity.status(504).body(Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"error", Map.of("code", -32603, "message", "인터페이스 서버 연결 시간 초과 (Timeout)"),
|
||||
"id", trackId
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 시스템 예외 에러 발생: {}", trackId, e.getMessage());
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"error", Map.of("code", -32000, "message", "내부 게이트웨이 서버 오류: " + e.getMessage()),
|
||||
"id", trackId
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 1. [신규] 표준 MCP 파이프라인 호출 (가장 중요!)
|
||||
@Operation(summary = "MCP 파이프라인 호출", description = "MCP 표준 파이프라인(ExecuteService)을 통해 레거시 툴을 호출합니다.")
|
||||
@PostMapping("/tools/call")
|
||||
public ResponseEntity<Object> callTool(@RequestBody Map<String, Object> payload, @RequestAttribute(value = "tenantId", required = false) String tenantId) {
|
||||
log.info("[MCP 표준] ExecuteService 파이프라인을 통한 툴 호출 시작 (Tenant: {})", tenantId);
|
||||
|
||||
try {
|
||||
return ResponseEntity.ok(executeService.execute(payload, tenantId));
|
||||
} catch (SecurityException se) {
|
||||
log.warn(" [보안 차단] 권한 오류: {}", se.getMessage());
|
||||
return ResponseEntity.status(403).body(Map.of("error", se.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error(" 파이프라인 실행 중 오류: {}", e.getMessage());
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 기존 표준 규격 메서드들 (유지 및 동적 Registry 반영)
|
||||
@GetMapping("/tools/list")
|
||||
public ResponseEntity<JsonRpcResponse> listTools() {
|
||||
List<ToolMetadata> activeTools = redisRegistryService.getAllTools();
|
||||
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(UUID.randomUUID().toString());
|
||||
response.setResult(Map.of("tools", activeTools));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/registry/register")
|
||||
public ResponseEntity<String> registerTool(@RequestBody ToolMetadata meta) {
|
||||
redisRegistryService.saveTool(meta);
|
||||
return ResponseEntity.ok("Registered");
|
||||
}
|
||||
|
||||
@PostMapping("/registry/deregister")
|
||||
public ResponseEntity<String> deregisterTool(@RequestBody String toolName) {
|
||||
redisRegistryService.removeTool(toolName);
|
||||
return ResponseEntity.ok("Deregistered");
|
||||
}
|
||||
|
||||
@PostMapping("/registry/heartbeat")
|
||||
public ResponseEntity<String> heartbeat(@RequestBody String toolName) {
|
||||
redisRegistryService.refreshHeartbeat(toolName);
|
||||
return ResponseEntity.ok("Heartbeat updated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tool(Agent)의 명세 및 라우팅 정보를 담고 있는 메타데이터 클래스
|
||||
* Redis 레지스트리에 저장되며, Planner와 Router 간의 통신 객체(Plan)로 사용됩니다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ToolMetadata {
|
||||
|
||||
// 1. Tool 기본 정보
|
||||
private String toolName; // 툴 고유 명칭 (예: CustomerSearchTool)
|
||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
||||
|
||||
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
|
||||
private Map<String, Object> parametersSchema;
|
||||
|
||||
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
|
||||
private Map<String, String> actionPrompts;
|
||||
|
||||
// 2-1. 도메인 부서 그룹명 (권한 관리에 사용)
|
||||
private String domainGroup;
|
||||
|
||||
// 2-2. 툴 처리 엔드포인트 URI 경로 (예: /api/tool/customer-info)
|
||||
private String endpoint;
|
||||
|
||||
// 2-3. Pod 실행 URL (독립적인 Microservice 라우팅용, 예: http://localhost:8082)
|
||||
private String podUrl;
|
||||
|
||||
// 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초당 허용 최대 요청 수
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.messaging;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KafkaProducerService {
|
||||
|
||||
// Spring Kafka 제공 템플릿
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
private final ObjectMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* 트래픽 초과 등의 이유로 즉시 처리가 불가능한 요청을 Kafka 대기열로 보냅니다.
|
||||
* @param topic 발행할 Kafka 토픽명
|
||||
* @param payload 원본 요청 페이로드
|
||||
* @return 발급된 고유 티켓 ID
|
||||
*/
|
||||
public String queueRequest(String topic, Map<String, Object> payload) {
|
||||
String ticketId = "TICKET-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
|
||||
// 페이로드에 티켓 ID 추가 (Consumer가 알 수 있도록)
|
||||
payload.put("ticketId", ticketId);
|
||||
payload.put("queuedAt", System.currentTimeMillis());
|
||||
|
||||
try {
|
||||
String jsonPayload = jsonMapper.writeValueAsString(payload);
|
||||
// 실제 Kafka 서버로 전송
|
||||
kafkaTemplate.send(topic, ticketId, jsonPayload);
|
||||
log.info("🎫 [Kafka Queue] 요청 대기열 등록 완료 - Topic: {}, Ticket ID: {}", topic, ticketId);
|
||||
} catch (Exception e) {
|
||||
// 로컬 테스트 시 실제 Kafka 브로커가 없어서 나는 예외를 방어합니다.
|
||||
// 실제 상용 환경에서는 Dead Letter Queue 등을 태우거나 예외 처리 정책에 따릅니다.
|
||||
log.error(" [Kafka Queue] Kafka 서버 통신 실패 (Broker Down). Mock 모드로 티켓은 발급합니다. Error: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return ticketId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.registry;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RedisRegistryService {
|
||||
|
||||
private final RedisTemplate<String, ToolMetadata> redisTemplate;
|
||||
private static final String KEY_PREFIX = "mcp:tool:";
|
||||
private static final Duration DEFAULT_TTL = Duration.ofSeconds(60); // 다이어그램 3-3 하트비트 주기 반영
|
||||
|
||||
/**
|
||||
* 툴 등록 및 갱신 (TTL 기반으로 60초 뒤 자동 만료)
|
||||
*/
|
||||
public void saveTool(ToolMetadata meta) {
|
||||
String key = KEY_PREFIX + meta.getToolName();
|
||||
meta.setLastHeartbeat(System.currentTimeMillis());
|
||||
redisTemplate.opsForValue().set(key, meta, DEFAULT_TTL);
|
||||
log.info(" [RedisRegistry] 툴 등록 완료: {}", meta.getToolName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 하트비트 갱신 (TTL 초기화)
|
||||
*/
|
||||
public void refreshHeartbeat(String toolName) {
|
||||
String key = KEY_PREFIX + toolName;
|
||||
Boolean exists = redisTemplate.expire(key, DEFAULT_TTL);
|
||||
if (Boolean.TRUE.equals(exists)) {
|
||||
log.debug(" [RedisRegistry] 하트비트 갱신: {}", toolName);
|
||||
} else {
|
||||
log.warn(" [RedisRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", toolName);
|
||||
}
|
||||
}
|
||||
public List<String> getAvailablePods(String toolName) {
|
||||
ToolMetadata tool = getTool(toolName);
|
||||
|
||||
if (tool != null && tool.getPodUrl() != null) {
|
||||
return List.of(tool.getPodUrl());
|
||||
}
|
||||
|
||||
return List.of();
|
||||
}
|
||||
/**
|
||||
* 실행 시 툴 정보 조회 (Tool Execution 시 참조)
|
||||
*/
|
||||
public ToolMetadata getTool(String toolName) {
|
||||
return redisTemplate.opsForValue().get(KEY_PREFIX + toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 모든 활성 툴 목록 조회
|
||||
*/
|
||||
public List<ToolMetadata> getAllTools() {
|
||||
Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ToolMetadata> tools = redisTemplate.opsForValue().multiGet(keys);
|
||||
return tools != null ? tools.stream().filter(Objects::nonNull).collect(Collectors.toList()) : List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴 명시적 제거 (Deregister)
|
||||
*/
|
||||
public void removeTool(String toolName) {
|
||||
redisTemplate.delete(KEY_PREFIX + toolName);
|
||||
log.info(" [RedisRegistry] 툴 삭제 완료: {}", toolName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.router;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RequestValidator {
|
||||
|
||||
// 파이프라인 진입을 위해 반드시 필요한 필수 필드 목록
|
||||
private static final List<String> REQUIRED_FIELDS = List.of("traceId", "agentId", "userPrompt");
|
||||
|
||||
public void validate(Map<String, Object> payload) {
|
||||
log.info(" [Validator] 파라미터 및 스키마 검증 시작");
|
||||
|
||||
// 1. 페이로드 자체 Null 또는 Empty 체크
|
||||
if (payload == null || payload.isEmpty()) {
|
||||
log.error(" [Validator] 검증 실패: 페이로드가 비어 있습니다.");
|
||||
throw new IllegalArgumentException("요청 페이로드가 존재하지 않습니다.");
|
||||
}
|
||||
|
||||
// 2. 필수 파라미터 누락 및 빈 값 검증
|
||||
for (String field : REQUIRED_FIELDS) {
|
||||
if (!payload.containsKey(field)) {
|
||||
log.error(" [Validator] 검증 실패: 필수 키 누락 [{}]", field);
|
||||
throw new IllegalArgumentException("필수 파라미터가 누락되었습니다: " + field);
|
||||
}
|
||||
|
||||
Object value = payload.get(field);
|
||||
if (value == null || value.toString().trim().isEmpty()) {
|
||||
log.error(" [Validator] 검증 실패: 필수 키의 값이 비어 있음 [{}]", field);
|
||||
throw new IllegalArgumentException("필수 파라미터의 값이 비어있을 수 없습니다: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 비즈니스 로직에 따른 추가 데이터 길이 또는 타입 검증 (예: 프롬프트 길이)
|
||||
String traceId = payload.get("traceId").toString();
|
||||
String userPrompt = payload.get("userPrompt").toString();
|
||||
|
||||
if (userPrompt.length() > 2000) {
|
||||
log.warn(" [Validator] 프롬프트 길이 초과 (Trace ID: {})", traceId);
|
||||
throw new IllegalArgumentException("프롬프트 길이는 2000자를 초과할 수 없습니다.");
|
||||
}
|
||||
|
||||
log.info(" [Validator] 스키마 검증 완료 (Trace ID: {})", traceId);
|
||||
|
||||
// TODO: 향후 더 복잡한 JSON Schema 파일(schema.json) 기반의 엄격한 검증이 필요해지면,
|
||||
// networknt/json-schema-validator 라이브러리를 도입하여 이 부분을 교체할 수 있습니다.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.router;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ResponseAggregator {
|
||||
public Object normalize(Object rawResponse) {
|
||||
log.info("📊 [Aggregator] 결과 병합 및 응답 포맷 정규화");
|
||||
return rawResponse; // 최종 응답 반환
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.router;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolRouter {
|
||||
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
|
||||
// application.yml 등에 정의된 신한라이프 EAI 표준 게이트웨이 주소
|
||||
@Value("${shinhan.eai.gateway.url:http://eai.internal.shinhanlife.com/api/v1/invoke}")
|
||||
private String eaiGatewayUrl;
|
||||
|
||||
/**
|
||||
* Planner에서 생성된 실행 계획(ToolMetadata)을 바탕으로
|
||||
* Client가 호출할 최종 타겟 정보(URL 및 필수 헤더/파라미터)를 라우팅합니다.
|
||||
*
|
||||
* @param plan ToolPlanner에서 반환한 실행 계획 (ToolMetadata)
|
||||
* @return Client에게 전달될 라우팅 정보 Map (targetUrl, integrationType, mciServiceId 등)
|
||||
*/
|
||||
public Map<String, Object> route(Object plan) {
|
||||
log.info("📍 [Router] 타겟 시스템 라우팅 분석 시작");
|
||||
|
||||
// 1. Planner에서 넘어온 plan을 ToolMetadata로 안전하게 캐스팅 (Java 16+ 패턴 매칭)
|
||||
if (!(plan instanceof ToolMetadata toolMetadata)) {
|
||||
log.error(" [Router] 잘못된 Plan 객체 타입 전달");
|
||||
throw new IllegalArgumentException("올바르지 않은 실행 계획(Plan) 타입입니다.");
|
||||
}
|
||||
|
||||
String toolName = toolMetadata.getToolName();
|
||||
String integrationType = toolMetadata.getIntegrationType();
|
||||
|
||||
Map<String, Object> routingResult = new HashMap<>();
|
||||
routingResult.put("toolName", toolName);
|
||||
routingResult.put("integrationType", integrationType);
|
||||
|
||||
// 2. 분기 처리: 레거시(MCI/EAI) 연동 vs 내부 직접(DIRECT) 연동
|
||||
if ("MCI_EAI".equalsIgnoreCase(integrationType)) {
|
||||
// [A] MCI/EAI 연동 로직
|
||||
String mciServiceId = toolMetadata.getMciServiceId();
|
||||
if (mciServiceId == null || mciServiceId.isBlank()) {
|
||||
log.error(" [Router] EAI 라우팅 실패: mciServiceId가 누락되었습니다. (Tool: {})", toolName);
|
||||
throw new IllegalStateException("MCI/EAI 연동 툴의 필수값(Service ID)이 누락되었습니다.");
|
||||
}
|
||||
|
||||
log.info(" [Router] MCI/EAI 연계 라우팅 - Tool: {}, Service ID: {}", toolName, mciServiceId);
|
||||
|
||||
// EAI 고정 서버 주소와 Service ID를 결과에 담아 Client로 전달
|
||||
routingResult.put("targetUrl", eaiGatewayUrl);
|
||||
routingResult.put("mciServiceId", mciServiceId);
|
||||
|
||||
} else {
|
||||
// [B] 내부 마이크로서비스 직접(DIRECT) 연동 로직
|
||||
log.info(" [Router] 내부 DIRECT 연계 라우팅 - Tool: {}", toolName);
|
||||
|
||||
// Redis에서 현재 살아있는 해당 툴의 활성 Pod(URL) 목록 조회
|
||||
List<String> availablePods = redisRegistryService.getAvailablePods(toolName);
|
||||
|
||||
if (availablePods == null || availablePods.isEmpty()) {
|
||||
log.error(" [Router] 라우팅 실패: '{}' 툴을 처리할 수 있는 활성 Pod가 없습니다.", toolName);
|
||||
throw new IllegalStateException("활성화된 대상 Tool Pod를 찾을 수 없습니다: " + toolName);
|
||||
}
|
||||
|
||||
// 다중 Pod 중 하나를 선택 (Random 로드밸런싱)
|
||||
String targetUrl = selectPodLoadBalanced(availablePods);
|
||||
log.info(" [Router] DIRECT 라우팅 완료 - 선택된 타겟 URL: {}", targetUrl);
|
||||
|
||||
routingResult.put("targetUrl", targetUrl);
|
||||
}
|
||||
|
||||
// 3. Client 계층에서 즉시 활용할 수 있도록 라우팅 결과 반환
|
||||
return routingResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 인스턴스(Pod) 환경을 위한 클라이언트 사이드 로드밸런싱 로직 (랜덤 방식)
|
||||
*
|
||||
* @param availablePods 살아있는 타겟 URL 리스트
|
||||
* @return 로드밸런싱을 통해 선택된 단일 URL
|
||||
*/
|
||||
private String selectPodLoadBalanced(List<String> availablePods) {
|
||||
if (availablePods.size() == 1) {
|
||||
return availablePods.get(0);
|
||||
}
|
||||
// Java ThreadLocalRandom을 사용하여 동시성 이슈 없이 난수 생성
|
||||
int randomIndex = ThreadLocalRandom.current().nextInt(availablePods.size());
|
||||
return availablePods.get(randomIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.router.RequestValidator;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.router.ToolRouter;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.router.ResponseAggregator;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.client.ToolClient;
|
||||
import java.util.Map;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExecuteService {
|
||||
|
||||
// 파이프라인의 핵심 컴포넌트들
|
||||
private final RequestValidator validator;
|
||||
private final ToolPlanner planner;
|
||||
private final ToolRouter router;
|
||||
private final ToolClient client;
|
||||
private final ResponseAggregator aggregator;
|
||||
private final KillSwitchService killSwitchService;
|
||||
|
||||
public Object execute(Map<String, Object> payload, String tenantId) {
|
||||
log.info(" [ExecuteService] 전체 실행 흐름 제어 시작");
|
||||
|
||||
// [비상 차단 1단계] Agent 단위 차단 검사
|
||||
killSwitchService.checkAgent(tenantId);
|
||||
|
||||
// [비상 차단 2단계] Tool 단위 차단 검사
|
||||
String toolName = (String) payload.get("toolName");
|
||||
killSwitchService.checkTool(toolName);
|
||||
|
||||
// 1. 검증 (Validator)
|
||||
validator.validate(payload);
|
||||
|
||||
// 2. 실행 계획 생성 및 권한 검증 (Planner)
|
||||
var planObj = planner.createPlan(payload, tenantId);
|
||||
ToolMetadata plan = (ToolMetadata) planObj;
|
||||
|
||||
// [비상 차단 3단계] Route/Legacy 단위 차단 검사
|
||||
if (plan.getIntegrationType() != null) {
|
||||
killSwitchService.checkRoute(plan.getIntegrationType());
|
||||
}
|
||||
|
||||
// 3. 라우팅 (Router) - Redis의 Tool Registry 참조
|
||||
var targetPod = router.route(plan);
|
||||
|
||||
// 4. 실제 Tool 호출 (Client) - Retry/Timeout 포함 (동적 제어 적용)
|
||||
var rawResponse = client.call(targetPod, payload, plan);
|
||||
|
||||
// 5. 응답 정규화 및 병합 (Aggregator)
|
||||
return aggregator.normalize(rawResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KillSwitchService {
|
||||
|
||||
// 기본 제공되는 StringRedisTemplate 사용
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public void checkAgent(String tenantId) {
|
||||
String key = "mcp:kill:agent:" + tenantId;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 에이전트 접근 시도: {}", tenantId);
|
||||
throw new SecurityException(" 비상 차단: 해당 테넌트(" + tenantId + ")의 접근이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public void checkTool(String toolName) {
|
||||
if (toolName == null || toolName.isBlank()) return;
|
||||
|
||||
String key = "mcp:kill:tool:" + toolName;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 툴 실행 시도: {}", toolName);
|
||||
throw new SecurityException(" 비상 차단: 해당 툴(" + toolName + ")의 실행이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public void checkRoute(String integrationType) {
|
||||
if (integrationType == null || integrationType.isBlank()) return;
|
||||
|
||||
String key = "mcp:kill:route:" + integrationType;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 라우트 통신 시도: {}", integrationType);
|
||||
throw new SecurityException(" 비상 차단: 해당 레거시 라우트(" + integrationType + ")로의 통신이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 API용 토글 메서드
|
||||
public void toggleKillSwitch(String type, String target, boolean state) {
|
||||
String key = "mcp:kill:" + type + ":" + target;
|
||||
stringRedisTemplate.opsForValue().set(key, String.valueOf(state));
|
||||
log.info(" [KillSwitch] 상태 변경: {} -> {} (차단 상태: {})", type, target, state);
|
||||
}
|
||||
|
||||
// 관리자 API용 삭제 메서드 (Redis 키 자체를 완전 삭제)
|
||||
public void removeKillSwitch(String type, String target) {
|
||||
String key = "mcp:kill:" + type + ":" + target;
|
||||
stringRedisTemplate.delete(key);
|
||||
log.info(" [KillSwitch] 차단 키 완전 삭제: {} -> {}", type, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.service;
|
||||
|
||||
import io.shinhanlife.axhub.common.mcp.security.SecurityProperties;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolPlanner {
|
||||
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
/**
|
||||
* 요청(payload)을 분석하여 실행해야 할 Tool의 계획을 생성합니다.
|
||||
*/
|
||||
public Object createPlan(Map<String, Object> payload, String tenantId) {
|
||||
log.info("📝 [Planner] 요청 분석 및 실행 계획 수립 시작");
|
||||
|
||||
// 1. 요청에서 호출하려는 툴 이름 추출
|
||||
String toolName = (String) payload.get("toolName");
|
||||
|
||||
if (toolName == null || toolName.isEmpty()) {
|
||||
throw new IllegalArgumentException("요청에 toolName이 포함되어 있지 않습니다.");
|
||||
}
|
||||
|
||||
// 2. RedisRegistry에서 해당 툴의 메타데이터 조회
|
||||
// (실제로는 이 메타데이터가 실행 계획의 핵심이 됩니다)
|
||||
var toolMetadata = redisRegistryService.getTool(toolName);
|
||||
|
||||
if (toolMetadata == null) {
|
||||
log.warn(" [Planner] 등록되지 않은 툴 요청: {}", toolName);
|
||||
throw new RuntimeException("해당 툴(" + toolName + ")이 레지스트리에 존재하지 않습니다.");
|
||||
}
|
||||
|
||||
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증
|
||||
if (tenantId != null && toolMetadata.getDomainGroup() != null) {
|
||||
List<String> allowedDomains = securityProperties.getTenantDomains().get(tenantId);
|
||||
if (allowedDomains == null ||
|
||||
(!allowedDomains.contains("ALL") && !allowedDomains.contains(toolMetadata.getDomainGroup()))) {
|
||||
log.warn(" [Planner] 권한 거부 - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getDomainGroup());
|
||||
throw new SecurityException("해당 도메인(" + toolMetadata.getDomainGroup() + ")의 툴을 실행할 권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
log.info(" [Planner] 툴 '{}'에 대한 실행 계획 수립 완료", toolName);
|
||||
|
||||
// 3. 수립된 계획(툴 메타데이터) 반환
|
||||
return toolMetadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface McpFunction {
|
||||
String name();
|
||||
String description();
|
||||
String prompt() default "";
|
||||
String mappingId() default "";
|
||||
|
||||
// 추가: 해당 함수가 요구하는 비즈니스 파라미터(JSON 형태의 properties)를 정의
|
||||
String parameterSchema() default "{}";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface McpParameter {
|
||||
String description();
|
||||
boolean required() default false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface McpTool {
|
||||
@AliasFor(annotation = Component.class)
|
||||
String value() default "";
|
||||
|
||||
String name();
|
||||
String description();
|
||||
String group() default "group_1";
|
||||
String routingType() default "HTTP";
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.controller;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolConfig;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.registry.MockToolAutoRegistrar;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.networknt.schema.JsonSchema;
|
||||
import com.networknt.schema.JsonSchemaFactory;
|
||||
import com.networknt.schema.SpecVersion;
|
||||
import com.networknt.schema.ValidationMessage;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/tool")
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessToolController {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final MockToolAutoRegistrar toolRegistrar;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// 단일 동적 라우팅 엔드포인트
|
||||
@PostMapping("/execute/{toolName}")
|
||||
public Map<String, Object> executeDynamicTool(@PathVariable String toolName, @RequestBody(required = false) Map<String, Object> payload) {
|
||||
log.info("\n [Tool] 동적 툴 실행 요청 수신: {}", toolName);
|
||||
|
||||
// 1. 등록된 툴 설정 조회
|
||||
ToolConfig config = toolRegistrar.getTools().stream()
|
||||
.filter(t -> t.getName().equals(toolName))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (config == null) {
|
||||
log.error(" [Tool] 등록되지 않은 툴 호출: {}", toolName);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", "등록되지 않은 툴입니다: " + toolName);
|
||||
return error;
|
||||
}
|
||||
|
||||
// 2. 파라미터에서 실행할 함수(action) 추출 및 라우팅 결정
|
||||
String action = payload != null && payload.containsKey("action") ? payload.get("action").toString() : null;
|
||||
|
||||
ToolFunction targetFunction = null;
|
||||
if (action != null) {
|
||||
targetFunction = config.getFunctions().stream()
|
||||
.filter(f -> f.getName().equals(action))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// action 파라미터가 없거나 일치하는 함수가 없으면 첫 번째 함수를 기본값으로 사용
|
||||
if (targetFunction == null && !config.getFunctions().isEmpty()) {
|
||||
targetFunction = config.getFunctions().get(0);
|
||||
log.warn(" [Tool] 지정된 action 파라미터가 없거나 유효하지 않아 기본 함수({})를 사용합니다.", targetFunction.getName());
|
||||
}
|
||||
|
||||
String interfaceId = targetFunction != null ? targetFunction.getInterfaceId() : "UNKNOWN";
|
||||
|
||||
// 2-1. 파라미터 유효성 검증 (JSON Schema)
|
||||
if (targetFunction != null && targetFunction.getParameterSchema() != null && !targetFunction.getParameterSchema().equals("{}")) {
|
||||
try {
|
||||
// parameterSchema에는 properties 내용만 들어있으므로 완전한 스키마 형태로 감싸줍니다.
|
||||
String fullSchemaJson = "{\"type\":\"object\", \"properties\":" + targetFunction.getParameterSchema() + "}";
|
||||
|
||||
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
|
||||
JsonSchema schema = factory.getSchema(fullSchemaJson);
|
||||
|
||||
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(payload));
|
||||
if (!errors.isEmpty()) {
|
||||
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
List<String> errorMessages = new ArrayList<>();
|
||||
for (ValidationMessage vm : errors) {
|
||||
errorMessages.add(vm.getMessage());
|
||||
}
|
||||
error.put("message", "파라미터 유효성 검증 실패: " + String.join(", ", errorMessages));
|
||||
return error;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 대상 Bean 찾기 (ApplicationContext 활용)
|
||||
Object targetBean = null;
|
||||
Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
|
||||
for (Object bean : toolBeans.values()) {
|
||||
McpTool mcpToolAnnotation = bean.getClass().getAnnotation(McpTool.class);
|
||||
if (mcpToolAnnotation != null && mcpToolAnnotation.name().equals(toolName)) {
|
||||
targetBean = bean;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetBean == null) {
|
||||
log.error("[Tool] 실행할 Tool Bean을 찾을 수 없습니다: {}", toolName);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", "실행할 Tool Bean을 찾을 수 없습니다: " + toolName);
|
||||
return error;
|
||||
}
|
||||
|
||||
// 4. 리플렉션을 통해 대상 메서드 직접 호출
|
||||
String actionName = targetFunction.getName();
|
||||
Method targetMethod = null;
|
||||
for (Method method : targetBean.getClass().getDeclaredMethods()) {
|
||||
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
|
||||
if (mcpFunc != null && mcpFunc.name().equals(actionName)) {
|
||||
targetMethod = method;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMethod == null) {
|
||||
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}", actionName);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", "실행할 함수(Method)를 찾을 수 없습니다: " + actionName);
|
||||
return error;
|
||||
}
|
||||
|
||||
log.info("[Tool] 리플렉션 직접 실행 -> Tool: {}, Method: {}", toolName, targetMethod.getName());
|
||||
|
||||
try {
|
||||
// 5. DTO 파라미터 자동 매핑 (Map -> DTO)
|
||||
Object invokeArgument = payload;
|
||||
if (targetMethod.getParameterCount() > 0) {
|
||||
Class<?> paramType = targetMethod.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
invokeArgument = objectMapper.convertValue(payload, paramType);
|
||||
log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 메서드 실행
|
||||
Object methodResult = targetMethod.invoke(targetBean, invokeArgument);
|
||||
|
||||
// 6. 결과 반환 (Map 타입으로 캐스팅 보장)
|
||||
if (methodResult instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> resultMap = (Map<String, Object>) methodResult;
|
||||
resultMap.put("ai_insight", "다이렉트 메서드(" + targetMethod.getName() + ") 통신이 성공적으로 수행되었습니다.");
|
||||
return resultMap;
|
||||
} else {
|
||||
Map<String, Object> resultMap = new HashMap<>();
|
||||
resultMap.put("status", "SUCCESS");
|
||||
resultMap.put("legacy_response", methodResult);
|
||||
resultMap.put("ai_insight", "다이렉트 메서드(" + targetMethod.getName() + ") 통신이 성공적으로 수행되었습니다.");
|
||||
return resultMap;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage());
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", "메서드 실행 중 예외 발생: " + e.getCause().getMessage());
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class BillingProcessReq {
|
||||
|
||||
@McpParameter(description = "처리할 청구 접수 번호", required = true)
|
||||
private String billingId;
|
||||
|
||||
@McpParameter(description = "심사 승인 여부 (예: APPROVE, REJECT)")
|
||||
private String approvalStatus;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class BillingStatusReq {
|
||||
|
||||
@McpParameter(description = "조회할 청구 접수 번호", required = true)
|
||||
private String billingId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class BondCheckReq {
|
||||
|
||||
@McpParameter(description = "확인하고자 하는 디지털 증권 발행 금액", required = true)
|
||||
private Long amount;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class BondIssueReq {
|
||||
|
||||
@McpParameter(description = "발행할 디지털 증권 금액", required = true)
|
||||
private Long amount;
|
||||
|
||||
@McpParameter(description = "발행 대상 계좌 번호", required = true)
|
||||
private String targetAccount;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ContractDetailReq {
|
||||
|
||||
@McpParameter(description = "고객명", required = true)
|
||||
private String customerName;
|
||||
|
||||
@McpParameter(description = "조회할 계약 번호", required = true)
|
||||
private String contractId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ContractStatusReq {
|
||||
|
||||
@McpParameter(description = "고객명", required = true)
|
||||
private String customerName;
|
||||
|
||||
@McpParameter(description = "조회할 계약 번호")
|
||||
private String contractId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class CustomerDetailReq {
|
||||
|
||||
@McpParameter(description = "고객명", required = true)
|
||||
private String customerName;
|
||||
|
||||
@McpParameter(description = "고객 식별 번호 (CID)")
|
||||
private String customerId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class CustomerGradeReq {
|
||||
|
||||
@McpParameter(description = "고객 이름 (예: 김신한)", required = true)
|
||||
private String customerName;
|
||||
|
||||
@McpParameter(description = "고객 식별 번호 (CID)")
|
||||
private String customerId;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class EmailSendReq {
|
||||
|
||||
@McpParameter(description = "수신자 이메일 주소", required = true)
|
||||
private String emailAddress;
|
||||
|
||||
@McpParameter(description = "이메일 제목", required = true)
|
||||
private String subject;
|
||||
|
||||
@McpParameter(description = "이메일 본문 내용", required = true)
|
||||
private String body;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class LeaveCountReq {
|
||||
|
||||
@McpParameter(description = "연차 내역을 조회할 사원 번호", required = true)
|
||||
private String employeeId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class SmsSendReq {
|
||||
|
||||
@McpParameter(description = "수신자 전화번호", required = true)
|
||||
private String phoneNumber;
|
||||
|
||||
@McpParameter(description = "전송할 메시지 내용", required = true)
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class VacationRegisterReq {
|
||||
|
||||
@McpParameter(description = "연차를 등록할 사원 번호", required = true)
|
||||
private String employeeId;
|
||||
|
||||
@McpParameter(description = "휴가 일자 (YYYY-MM-DD 형식)", required = true)
|
||||
private String date;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@AllArgsConstructor
|
||||
public class ToolConfig {
|
||||
private String name;
|
||||
private String description;
|
||||
private String path;
|
||||
private String group;
|
||||
|
||||
// 추가: 레거시 통신용 동적 설정
|
||||
private String routingType;
|
||||
private List<ToolFunction> functions;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@AllArgsConstructor
|
||||
public class ToolFunction {
|
||||
private String name;
|
||||
private String description;
|
||||
private String interfaceId;
|
||||
private String parameterSchema;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.registry;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolConfig;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.util.JsonSchemaGenerator;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MockToolAutoRegistrar {
|
||||
|
||||
private final RestClient restClient = RestClient.builder().build();
|
||||
private final ApplicationContext applicationContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${mcp.gateway.url:http://localhost:8081/mcp/api/v1}")
|
||||
private String gatewayUrl;
|
||||
private final String API_KEY = "SHINHAN_MCP_TEST_KEY_9999";
|
||||
|
||||
@Value("${server.port:8081}")
|
||||
private int serverPort;
|
||||
|
||||
@Value("${mcp.tool.target:all}")
|
||||
private String toolTarget;
|
||||
|
||||
@Value("${mcp.tool.host:localhost}")
|
||||
private String toolHost;
|
||||
|
||||
private List<ToolConfig> TOOLS = new ArrayList<>();
|
||||
|
||||
public MockToolAutoRegistrar(ApplicationContext applicationContext, ObjectMapper objectMapper) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
if (serverPort == 8081) return; // Gateway skip
|
||||
|
||||
log.info(" [AutoDiscovery] @McpTool 어노테이션 스캔 시작...");
|
||||
Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
|
||||
|
||||
for (Object bean : toolBeans.values()) {
|
||||
McpTool mcpTool = bean.getClass().getAnnotation(McpTool.class);
|
||||
if (mcpTool == null) continue;
|
||||
|
||||
// Target Group 필터링
|
||||
if (!"all".equals(toolTarget) && !mcpTool.name().equals(toolTarget) && !mcpTool.group().equals(toolTarget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ToolFunction> functions = new ArrayList<>();
|
||||
for (Method method : bean.getClass().getDeclaredMethods()) {
|
||||
if (method.isAnnotationPresent(McpFunction.class)) {
|
||||
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
|
||||
String finalSchema = mcpFunc.parameterSchema();
|
||||
if (method.getParameterCount() > 0) {
|
||||
Class<?> paramType = method.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
try {
|
||||
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
|
||||
finalSchema = objectMapper.writeValueAsString(autoSchema);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate schema for {}", paramType.getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
functions.add(new ToolFunction(mcpFunc.name(), mcpFunc.description(), mcpFunc.mappingId(), finalSchema));
|
||||
}
|
||||
}
|
||||
|
||||
ToolConfig config = new ToolConfig(
|
||||
mcpTool.name(),
|
||||
mcpTool.description(),
|
||||
"/api/tool/execute/" + mcpTool.name(),
|
||||
mcpTool.group(),
|
||||
mcpTool.routingType(),
|
||||
functions
|
||||
);
|
||||
TOOLS.add(config);
|
||||
log.info(" [AutoDiscovery] 툴 발견: {} (함수 {}개)", mcpTool.name(), functions.size());
|
||||
}
|
||||
}
|
||||
|
||||
public List<ToolConfig> getTools() {
|
||||
return TOOLS;
|
||||
}
|
||||
|
||||
private HttpHeaders createHeaders() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-KEY", API_KEY);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void registerOnStartup() {
|
||||
if (serverPort == 8081) return;
|
||||
|
||||
log.info("[MockTool] Tool Pod 기동 완료! (Port: {})", serverPort);
|
||||
|
||||
for (Object bean : applicationContext.getBeansWithAnnotation(McpTool.class).values()) {
|
||||
McpTool mcpTool = bean.getClass().getAnnotation(McpTool.class);
|
||||
if (!"all".equals(toolTarget) && !mcpTool.name().equals(toolTarget) && !mcpTool.group().equals(toolTarget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ToolMetadata myMetadata = new ToolMetadata();
|
||||
myMetadata.setToolName(mcpTool.name());
|
||||
myMetadata.setDescription(mcpTool.description());
|
||||
myMetadata.setDomainGroup(mcpTool.group());
|
||||
myMetadata.setEndpoint("/api/tool/execute/" + mcpTool.name());
|
||||
myMetadata.setPodUrl("http://" + toolHost + ":" + serverPort);
|
||||
myMetadata.setIntegrationType(mcpTool.routingType());
|
||||
myMetadata.setFailureRateThreshold(50);
|
||||
myMetadata.setSlidingWindowSize(20);
|
||||
myMetadata.setRateLimitForPeriod(100);
|
||||
|
||||
Map<String, String> actionPrompts = new HashMap<>();
|
||||
List<String> actions = new ArrayList<>();
|
||||
List<String> actionDescs = new ArrayList<>();
|
||||
Map<String, Object> aggregatedProperties = new HashMap<>();
|
||||
|
||||
for (Method method : bean.getClass().getDeclaredMethods()) {
|
||||
if (method.isAnnotationPresent(McpFunction.class)) {
|
||||
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
|
||||
actions.add(mcpFunc.name());
|
||||
actionDescs.add(mcpFunc.name() + "(" + mcpFunc.description() + ")");
|
||||
actionPrompts.put(mcpFunc.name(), mcpFunc.prompt());
|
||||
|
||||
if (method.getParameterCount() > 0) {
|
||||
Class<?> paramType = method.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
// DTO 기반 자동 스키마 생성
|
||||
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
|
||||
aggregatedProperties.putAll(autoSchema);
|
||||
} else {
|
||||
// 기존 하드코딩 스키마 사용
|
||||
String schemaJson = mcpFunc.parameterSchema();
|
||||
if (!"{}".equals(schemaJson)) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> schemaMap = objectMapper.readValue(schemaJson, Map.class);
|
||||
aggregatedProperties.putAll(schemaMap);
|
||||
} catch (Exception e) {
|
||||
log.error(" [MockTool] Failed to parse parameterSchema for {}: {}", mcpFunc.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
myMetadata.setActionPrompts(actionPrompts);
|
||||
|
||||
Map<String, Object> parametersSchema = new HashMap<>();
|
||||
parametersSchema.put("type", "object");
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.putAll(aggregatedProperties);
|
||||
|
||||
Map<String, Object> actionProperty = new HashMap<>();
|
||||
actionProperty.put("type", "string");
|
||||
actionProperty.put("enum", actions);
|
||||
actionProperty.put("description", "실행할 함수명: " + String.join(", ", actionDescs));
|
||||
|
||||
properties.put("action", actionProperty);
|
||||
parametersSchema.put("properties", properties);
|
||||
parametersSchema.put("required", Arrays.asList("action"));
|
||||
|
||||
myMetadata.setParametersSchema(parametersSchema);
|
||||
|
||||
try {
|
||||
String registerUrl = gatewayUrl + "/registry/register";
|
||||
restClient.post()
|
||||
.uri(registerUrl)
|
||||
.headers(h -> h.addAll(createHeaders()))
|
||||
.body(myMetadata)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
log.info(" [MockTool] {} 자동 등록 성공! (Pod URL: {})", mcpTool.name(), myMetadata.getPodUrl());
|
||||
} catch (Exception e) {
|
||||
log.error(" [MockTool] {} 자동 등록 실패: {}", mcpTool.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("");
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30000)
|
||||
public void sendHeartbeat() {
|
||||
if (serverPort == 8081) return;
|
||||
|
||||
HttpHeaders headers = createHeaders();
|
||||
headers.setContentType(MediaType.TEXT_PLAIN);
|
||||
|
||||
for (ToolConfig tool : TOOLS) {
|
||||
try {
|
||||
String heartbeatUrl = gatewayUrl + "/registry/heartbeat";
|
||||
restClient.post()
|
||||
.uri(heartbeatUrl)
|
||||
.headers(h -> h.addAll(headers))
|
||||
.body(tool.getName())
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
log.info(" [MockTool] {} Heartbeat 서버 전송 완료", tool.getName());
|
||||
} catch (Exception e) {
|
||||
log.error(" [MockTool] {} Heartbeat 전송 실패: {}", tool.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener(ContextClosedEvent.class)
|
||||
public void deregisterOnShutdown() {
|
||||
if (serverPort == 8081) return;
|
||||
|
||||
log.info("\n [MockTool] Tool Pod (Port: {}) 종료 중... Gateway에 툴 해제를 요청합니다.", serverPort);
|
||||
HttpHeaders headers = createHeaders();
|
||||
headers.setContentType(MediaType.TEXT_PLAIN);
|
||||
|
||||
for (ToolConfig tool : TOOLS) {
|
||||
try {
|
||||
String deregisterUrl = gatewayUrl + "/registry/deregister";
|
||||
restClient.post()
|
||||
.uri(deregisterUrl)
|
||||
.headers(h -> h.addAll(headers))
|
||||
.body(tool.getName())
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
log.info(" [MockTool] {} 명시적 해제 성공!", tool.getName());
|
||||
} catch (Exception e) {
|
||||
log.error(" [MockTool] {} 명시적 해제 실패: {}", tool.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.util.PiiMaskingUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public abstract class AbstractMcpToolService {
|
||||
|
||||
@Autowired
|
||||
protected LegacyEimsConnector legacyEimsConnector;
|
||||
|
||||
@Autowired
|
||||
protected ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 레거시 시스템을 호출하고 공통 처리(PII 마스킹 등)를 수행합니다.
|
||||
*/
|
||||
protected Map<String, Object> executeLegacy(String routingType, String interfaceId, Object inputData) {
|
||||
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 {
|
||||
// 1. Adapter 공통 모듈 직접 호출
|
||||
String executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, inputMap, null);
|
||||
|
||||
// 2. PII 마스킹 처리
|
||||
String maskedResult = PiiMaskingUtils.mask(executionResult);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("status", "SUCCESS");
|
||||
result.put("legacy_response", maskedResult);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("status", "ERROR");
|
||||
error.put("message", e.getMessage());
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.BillingStatusReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.BillingProcessReq;
|
||||
|
||||
@McpTool(
|
||||
name = "billing_process_tool",
|
||||
description = "청구 심사 및 처리 상태 툴",
|
||||
group = "group_2",
|
||||
routingType = "MCI"
|
||||
)
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
public class BillingProcessService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(name = "status", description = "청구심사 상태 조회", prompt = "현재 접수된 청구건 상태를 알려줘.", mappingId = "BILL_001")
|
||||
public Object getStatus(BillingStatusReq data) {
|
||||
return executeBillingLogic("BILL_001", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "process", description = "청구 처리", prompt = "현재 접수된 청구건에 대한 심사 처리를 진행해.", mappingId = "BILL_002")
|
||||
public Object processBilling(BillingProcessReq data) {
|
||||
return executeBillingLogic("BILL_002", data);
|
||||
}
|
||||
|
||||
private Object executeBillingLogic(String mappingId, Object data) {
|
||||
log.info("💰 [Billing] 청구 처리 전용 커스텀 전/후처리 로직 수행 시작");
|
||||
|
||||
Map<String, Object> payload;
|
||||
if (data == null) {
|
||||
payload = new HashMap<>();
|
||||
} else {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
payload = mapper.convertValue(data, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
// 커스텀 전처리
|
||||
payload.put("custom_injected_data", "Billing System Check OK");
|
||||
log.info("💰 [Billing] 커스텀 파라미터 주입 완료");
|
||||
|
||||
// 부모 클래스의 레거시 공통 연동 메서드 호출 (PII 마스킹 포함)
|
||||
Map<String, Object> result = executeLegacy("MCI", mappingId, payload);
|
||||
|
||||
// 커스텀 후처리
|
||||
if ("SUCCESS".equals(result.get("status"))) {
|
||||
result.put("billing_custom_insight", "청구 특화 후처리 로직이 적용되었습니다.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.BondCheckReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.BondIssueReq;
|
||||
|
||||
@McpTool(
|
||||
name = "bond_issue_tool",
|
||||
description = "디지털 증권 발행 가능 여부 조회 툴",
|
||||
group = "group_3",
|
||||
routingType = "EAI"
|
||||
)
|
||||
public class BondIssueService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(name = "check", description = "발행 가능 여부 조회", prompt = "디지털 증권 발행 한도가 충분한지 확인해줘.", mappingId = "BOND_001")
|
||||
public Object check(BondCheckReq data) {
|
||||
return executeLegacy("EAI", "BOND_001", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "issue", description = "증권 발행", prompt = "디지털 증권 발행 프로세스를 실행해.", mappingId = "BOND_002")
|
||||
public Object issue(BondIssueReq data) {
|
||||
return executeLegacy("EAI", "BOND_002", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.VacationRegisterReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.LeaveCountReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.SmsSendReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.EmailSendReq;
|
||||
|
||||
@McpTool(
|
||||
name = "common_utility_tool",
|
||||
description = "전사 공통 유틸리티 툴 (HR 및 알림)",
|
||||
group = "group_1",
|
||||
routingType = "HTTP"
|
||||
)
|
||||
public class CommonUtilityService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(name = "register_vacation", description = "휴가 등록", prompt = "내일 하루 연차 휴가를 등록해줘.", mappingId = "HR_VAC_01")
|
||||
public Object registerVacation(VacationRegisterReq data) {
|
||||
return executeLegacy("HTTP", "HR_VAC_01", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "get_leave_count", description = "연차 갯수 조회", prompt = "현재 사용 가능한 남은 연차 일수를 알려줘.", mappingId = "HR_VAC_02")
|
||||
public Object getLeaveCount(LeaveCountReq data) {
|
||||
return executeLegacy("HTTP", "HR_VAC_02", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "send_sms", description = "SMS 발송", prompt = "고객님께 심사 완료 안내 SMS 문자를 발송해줘.", mappingId = "COM_SMS_01")
|
||||
public Object sendSms(SmsSendReq data) {
|
||||
return executeLegacy("HTTP", "COM_SMS_01", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "send_email", description = "이메일 발송", prompt = "담당자에게 결과 보고서 이메일을 보내줘.", mappingId = "COM_EML_01")
|
||||
public Object sendEmail(EmailSendReq data) {
|
||||
return executeLegacy("HTTP", "COM_EML_01", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.ContractStatusReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.ContractDetailReq;
|
||||
|
||||
@McpTool(
|
||||
name = "contract_inquiry_tool",
|
||||
description = "계약 상태 및 상세 정보 조회 툴",
|
||||
group = "group_2",
|
||||
routingType = "HTTP"
|
||||
)
|
||||
public class ContractInquiryService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(name = "status", description = "계약상태 조회", prompt = "김신한 고객의 현재 계약 상태를 조회해줘.", mappingId = "CNTR_001")
|
||||
public Object getStatus(ContractStatusReq data) {
|
||||
return executeLegacy("HTTP", "CNTR_001", data);
|
||||
}
|
||||
|
||||
@McpFunction(name = "detail", description = "계약상세 조회", prompt = "김신한 고객의 계약 상세 내역을 알려줘.", mappingId = "CNTR_002")
|
||||
public Object getDetail(ContractDetailReq data) {
|
||||
return executeLegacy("HTTP", "CNTR_002", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.CustomerGradeReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.CustomerDetailReq;
|
||||
|
||||
@McpTool(
|
||||
name = "customer_info_tool",
|
||||
description = "고객 등급 및 상세 정보 조회 툴",
|
||||
group = "group_3",
|
||||
routingType = "TCP"
|
||||
)
|
||||
public class CustomerInfoService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(name = "grade", description = "고객등급 조회", prompt = "이 고객의 VIP 등급을 조회해줘.", mappingId = "CRM_001")
|
||||
public Object getGrade(CustomerGradeReq req) {
|
||||
return executeLegacy("TCP", "CRM_001", req);
|
||||
}
|
||||
|
||||
@McpFunction(name = "detail", description = "고객상세 정보 조회", prompt = "이 고객의 상세 기본정보(주소, 연락처 등)를 알려줘.", mappingId = "CRM_002")
|
||||
public Object getDetail(CustomerDetailReq data) {
|
||||
return executeLegacy("TCP", "CRM_002", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.util;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class JsonSchemaGenerator {
|
||||
|
||||
/**
|
||||
* Java DTO 클래스를 분석하여 MCP 규격의 JSON Schema (Properties)를 생성합니다.
|
||||
*/
|
||||
public static Map<String, Object> generatePropertiesSchema(Class<?> clazz) {
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
Map<String, Object> fieldSchema = new HashMap<>();
|
||||
|
||||
// 1. 타입 매핑
|
||||
fieldSchema.put("type", mapJavaTypeToJsonType(field.getType()));
|
||||
|
||||
// 2. 어노테이션 기반 설명 추출
|
||||
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
|
||||
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
|
||||
fieldSchema.put("description", paramAnnotation.description());
|
||||
} else {
|
||||
fieldSchema.put("description", field.getName()); // 기본값
|
||||
}
|
||||
|
||||
properties.put(field.getName(), fieldSchema);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static String mapJavaTypeToJsonType(Class<?> clazz) {
|
||||
if (clazz == String.class) return "string";
|
||||
if (clazz == Integer.class || clazz == int.class) return "integer";
|
||||
if (clazz == Long.class || clazz == long.class) return "integer";
|
||||
if (clazz == Double.class || clazz == double.class) return "number";
|
||||
if (clazz == Float.class || clazz == float.class) return "number";
|
||||
if (clazz == Boolean.class || clazz == boolean.class) return "boolean";
|
||||
if (java.util.List.class.isAssignableFrom(clazz)) return "array";
|
||||
return "object";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스
|
||||
*
|
||||
* [실행 방법]
|
||||
* 방법 1. IDE(IntelliJ 등)에서 직접 실행 (대화형 모드 추천 ⭐)
|
||||
* - 이 클래스(ToolScaffolder.java)를 열고 main 메서드를 직접 실행(Run)합니다.
|
||||
* - 콘솔 창에 뜨는 질문에 차례대로 값을 입력하기만 하면 파일이 생성됩니다.
|
||||
*
|
||||
* 방법 2. 커맨드라인(터미널)에서 실행 (명령어 기반)
|
||||
* - 컴파일: javac -encoding UTF-8 src/main/java/com/shinhan/mcp/tool/util/ToolScaffolder.java
|
||||
* - 띄어쓰기를 포함한 인자와 함께 실행: java -cp src/main/java com.shinhan.mcp.tool.util.ToolScaffolder [이름] [ID] "[설명]"
|
||||
* - 실행 예시: java -cp src/main/java com.shinhan.mcp.tool.util.ToolScaffolder ExchangeRate EXCH_001 "환율 조회 기능"
|
||||
*/
|
||||
public class ToolScaffolder {
|
||||
|
||||
private static final String BASE_PACKAGE = "com.shinhan.mcp.tool";
|
||||
// 프로젝트 루트 기준 상대 경로
|
||||
private static final String BASE_PATH = "src/main/java/com/shinhan/mcp/tool";
|
||||
|
||||
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 소속 그룹 (예: group_1): ");
|
||||
String routingType = getOrAsk(args, 4, scanner, "5. 통신 프로토콜 (예: HTTP, TCP, MCI): ");
|
||||
if (routingType.trim().isEmpty()) {
|
||||
routingType = "HTTP";
|
||||
}
|
||||
|
||||
scaffold(baseName, interfaceId, description, group, routingType);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static void scaffold(String baseName, String interfaceId, String description, String group, String routingType) throws IOException {
|
||||
Path serviceDir = Paths.get(BASE_PATH, "service");
|
||||
Path dtoDir = Paths.get(BASE_PATH, "dto");
|
||||
|
||||
Files.createDirectories(serviceDir);
|
||||
Files.createDirectories(dtoDir);
|
||||
|
||||
String camelCaseName = baseName.substring(0, 1).toLowerCase() + baseName.substring(1);
|
||||
String toolName = camelCaseName + "_tool";
|
||||
|
||||
// Generate Req DTO
|
||||
String reqContent = """
|
||||
package %s.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class %sReq {
|
||||
// TODO: Add request fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, baseName);
|
||||
Files.writeString(dtoDir.resolve(baseName + "Req.java"), reqContent);
|
||||
|
||||
// Generate Res DTO
|
||||
String resContent = """
|
||||
package %s.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class %sRes {
|
||||
private String status;
|
||||
private String message;
|
||||
// TODO: Add response fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, baseName);
|
||||
Files.writeString(dtoDir.resolve(baseName + "Res.java"), resContent);
|
||||
|
||||
// Generate Service
|
||||
String serviceContent = """
|
||||
package %s.service;
|
||||
|
||||
import %s.annotation.McpFunction;
|
||||
import %s.annotation.McpTool;
|
||||
import %s.dto.%sReq;
|
||||
import %s.dto.%sRes;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@McpTool(
|
||||
name = "%s",
|
||||
description = "%s",
|
||||
group = "%s",
|
||||
routingType = "%s"
|
||||
)
|
||||
public class %sService extends AbstractMcpToolService {
|
||||
|
||||
@McpFunction(
|
||||
name = "execute",
|
||||
description = "%s 처리",
|
||||
mappingId = "%s"
|
||||
)
|
||||
public Object execute(%sReq req) {
|
||||
return executeLegacy("%s", "%s", req);
|
||||
}
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, BASE_PACKAGE, BASE_PACKAGE, BASE_PACKAGE, baseName, BASE_PACKAGE, baseName,
|
||||
toolName, description, group, routingType, baseName, description, interfaceId, baseName, routingType, interfaceId);
|
||||
|
||||
Files.writeString(serviceDir.resolve(baseName + "Service.java"), serviceContent);
|
||||
|
||||
System.out.println("\n=========================================");
|
||||
System.out.println("✅ Scaffolding Complete!");
|
||||
System.out.println("=========================================");
|
||||
System.out.println("[Service] " + serviceDir.resolve(baseName + "Service.java"));
|
||||
System.out.println("[Req DTO] " + dtoDir.resolve(baseName + "Req.java"));
|
||||
System.out.println("[Res DTO] " + dtoDir.resolve(baseName + "Res.java"));
|
||||
System.out.println("\n💡 팁: " + interfaceId + " 목업 데이터를 mock-responses.json에 추가하세요.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
// 스프링이 캐시를 관리할 기본 저장소를 빈(Bean)으로 등록합니다.
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
return new ConcurrentMapCacheManager("eimsData"); // 아까 설정한 캐시 이름 등록
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@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를 입력해주세요. (기본값: SHINHAN_MCP_SECRET_KEY_2026)")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.shinhanlife.axhub.common.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.axhub.common.mcp.security.ApiKeyInterceptor;
|
||||
|
||||
@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 경로는 인증 제외
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// Swagger UI(8080)에서 Gateway(8081)로 API 호출 시 발생하는 CORS 에러 해결
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.shinhanlife.axhub.common.mcp.exception;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.adapter.dto.ErrorDetail;
|
||||
import io.shinhanlife.axhub.biz.mcp.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;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice // 이 어노테이션이 전역 적용의 핵심입니다!
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.shinhanlife.axhub.common.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;
|
||||
|
||||
@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가 우리가 발급한 키 목록(Map)에 존재하는지 확인합니다.
|
||||
if (apiKey == null || !validApiKeys.containsKey(apiKey)) {
|
||||
log.warn(" [보안 차단] 유효하지 않은 API Key 접근 시도 - IP: {}", request.getRemoteAddr());
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid API Key");
|
||||
return false; // 컨트롤러로 넘어가지 않음
|
||||
}
|
||||
|
||||
// 3. 유효한 키라면, 해당 키와 맵핑된 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.shinhanlife.axhub.common.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 자료구조로 자동 바인딩(주입) 받기 위한 설정 클래스입니다.
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "mcp.security")
|
||||
public class SecurityProperties {
|
||||
// API Key를 Key로, Tenant ID를 Value로 가지는 맵 (ex. SHINHAN_MCP_SECRET_KEY_2026 -> mcp-client-1)
|
||||
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<>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user