From ba60c212d49346884cea711427f8044ae26ae651 Mon Sep 17 00:00:00 2001 From: jade Date: Fri, 3 Jul 2026 23:37:12 +0900 Subject: [PATCH] Migrate functional features from shinhan-mcp-prebuild to axhub-backend-main --- build.gradle | 7 + migrate2.ps1 | 33 +++ .../mcp/adapter/aop/EimsMonitoringAspect.java | 49 ++++ .../mcp/adapter/aop/GatewayLoggingAspect.java | 40 +++ .../connector/ExternalApiConnector.java | 58 ++++ .../connector/InternalSystemConnector.java | 60 +++++ .../adapter/connector/LegacyDbConnector.java | 44 +++ .../connector/LegacyEimsConnector.java | 117 ++++++++ .../ThirdPartySecurityConnector.java | 57 ++++ .../adapter/controller/MockEsbController.java | 24 ++ .../biz/mcp/adapter/dto/ErrorDetail.java | 13 + .../biz/mcp/adapter/dto/JsonRpcRequest.java | 13 + .../biz/mcp/adapter/dto/JsonRpcResponse.java | 14 + .../axhub/biz/mcp/adapter/dto/Params.java | 17 ++ .../biz/mcp/adapter/sender/EaiEimsSender.java | 90 +++++++ .../biz/mcp/adapter/sender/EimsSender.java | 6 + .../mcp/adapter/sender/HttpEimsSender.java | 45 ++++ .../mcp/adapter/sender/JspFormEimsSender.java | 44 +++ .../mcp/adapter/sender/JspJsonEimsSender.java | 45 ++++ .../biz/mcp/adapter/sender/MciEimsSender.java | 61 +++++ .../biz/mcp/adapter/sender/TcpEimsSender.java | 57 ++++ .../support/DynamicPayloadBuilder.java | 45 ++++ .../support/DynamicSchemaValidator.java | 33 +++ .../adapter/support/ResultStandardizer.java | 39 +++ .../mcp/adapter/support/TicketManager.java | 83 ++++++ .../adapter/support/ToolExecutionService.java | 76 ++++++ .../mcp/adapter/test/MockEimsHttpServer.java | 60 +++++ .../mcp/adapter/test/MockEimsTcpServer.java | 116 ++++++++ .../biz/mcp/adapter/test/MockJspServer.java | 44 +++ .../adapter/util/LegacyDataTransformer.java | 78 ++++++ .../util/PiiMaskingLogbackConverter.java | 21 ++ .../biz/mcp/adapter/util/PiiMaskingUtils.java | 63 +++++ .../biz/mcp/gateway/client/ToolClient.java | 167 ++++++++++++ .../biz/mcp/gateway/config/RedisConfig.java | 32 +++ .../controller/KillSwitchController.java | 55 ++++ .../controller/McpRouterController.java | 159 +++++++++++ .../biz/mcp/gateway/dto/ToolMetadata.java | 52 ++++ .../messaging/KafkaProducerService.java | 48 ++++ .../registry/RedisRegistryService.java | 81 ++++++ .../mcp/gateway/router/RequestValidator.java | 53 ++++ .../gateway/router/ResponseAggregator.java | 13 + .../biz/mcp/gateway/router/ToolRouter.java | 101 +++++++ .../mcp/gateway/service/ExecuteService.java | 59 ++++ .../gateway/service/KillSwitchService.java | 57 ++++ .../biz/mcp/gateway/service/ToolPlanner.java | 57 ++++ .../biz/mcp/tool/annotation/McpFunction.java | 16 ++ .../biz/mcp/tool/annotation/McpParameter.java | 11 + .../biz/mcp/tool/annotation/McpTool.java | 20 ++ .../controller/BusinessToolController.java | 181 +++++++++++++ .../biz/mcp/tool/dto/BillingProcessReq.java | 16 ++ .../biz/mcp/tool/dto/BillingStatusReq.java | 13 + .../axhub/biz/mcp/tool/dto/BondCheckReq.java | 13 + .../axhub/biz/mcp/tool/dto/BondIssueReq.java | 16 ++ .../biz/mcp/tool/dto/ContractDetailReq.java | 16 ++ .../biz/mcp/tool/dto/ContractStatusReq.java | 16 ++ .../biz/mcp/tool/dto/CustomerDetailReq.java | 16 ++ .../biz/mcp/tool/dto/CustomerGradeReq.java | 16 ++ .../axhub/biz/mcp/tool/dto/EmailSendReq.java | 19 ++ .../axhub/biz/mcp/tool/dto/LeaveCountReq.java | 13 + .../axhub/biz/mcp/tool/dto/SmsSendReq.java | 16 ++ .../biz/mcp/tool/dto/VacationRegisterReq.java | 16 ++ .../axhub/biz/mcp/tool/model/ToolConfig.java | 21 ++ .../biz/mcp/tool/model/ToolFunction.java | 15 ++ .../tool/registry/MockToolAutoRegistrar.java | 254 ++++++++++++++++++ .../tool/service/AbstractMcpToolService.java | 53 ++++ .../tool/service/BillingProcessService.java | 55 ++++ .../mcp/tool/service/BondIssueService.java | 25 ++ .../tool/service/CommonUtilityService.java | 37 +++ .../tool/service/ContractInquiryService.java | 25 ++ .../mcp/tool/service/CustomerInfoService.java | 25 ++ .../mcp/tool/util/JsonSchemaGenerator.java | 46 ++++ .../biz/mcp/tool/util/ToolScaffolder.java | 138 ++++++++++ .../axhub/common/mcp/config/CacheConfig.java | 18 ++ .../common/mcp/config/JacksonConfig.java | 27 ++ .../common/mcp/config/KafkaLocalConfig.java | 44 +++ .../common/mcp/config/SwaggerConfig.java | 33 +++ .../axhub/common/mcp/config/WebConfig.java | 38 +++ .../mcp/exception/GlobalExceptionHandler.java | 38 +++ .../common/mcp/filter/MdcLoggingFilter.java | 42 +++ .../mcp/security/ApiKeyInterceptor.java | 58 ++++ .../mcp/security/SecurityProperties.java | 27 ++ 81 files changed, 3919 insertions(+) create mode 100644 migrate2.ps1 create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/EimsMonitoringAspect.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/GatewayLoggingAspect.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ExternalApiConnector.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/InternalSystemConnector.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyDbConnector.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyEimsConnector.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ThirdPartySecurityConnector.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/controller/MockEsbController.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/ErrorDetail.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcRequest.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcResponse.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/Params.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EaiEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/HttpEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspFormEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspJsonEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/MciEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/TcpEimsSender.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicPayloadBuilder.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicSchemaValidator.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ResultStandardizer.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/TicketManager.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ToolExecutionService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsHttpServer.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsTcpServer.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockJspServer.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/LegacyDataTransformer.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingLogbackConverter.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingUtils.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/client/ToolClient.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/RedisConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/KillSwitchController.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/McpRouterController.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/messaging/KafkaProducerService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/registry/RedisRegistryService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/RequestValidator.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ResponseAggregator.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ToolRouter.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/KillSwitchService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ToolPlanner.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpFunction.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpParameter.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpTool.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/controller/BusinessToolController.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingProcessReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingStatusReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondCheckReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondIssueReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractDetailReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractStatusReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerDetailReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerGradeReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/EmailSendReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/LeaveCountReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/SmsSendReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/VacationRegisterReq.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolFunction.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/registry/MockToolAutoRegistrar.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/AbstractMcpToolService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BillingProcessService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BondIssueService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CommonUtilityService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/ContractInquiryService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CustomerInfoService.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/JsonSchemaGenerator.java create mode 100644 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/ToolScaffolder.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/config/CacheConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/config/JacksonConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/config/KafkaLocalConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/config/SwaggerConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/config/WebConfig.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/filter/MdcLoggingFilter.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/security/ApiKeyInterceptor.java create mode 100644 src/main/java/io/shinhanlife/axhub/common/mcp/security/SecurityProperties.java diff --git a/build.gradle b/build.gradle index 3e3d214..5ce10eb 100644 --- a/build.gradle +++ b/build.gradle @@ -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}" } diff --git a/migrate2.ps1 b/migrate2.ps1 new file mode 100644 index 0000000..7570335 --- /dev/null +++ b/migrate2.ps1 @@ -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) +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/EimsMonitoringAspect.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/EimsMonitoringAspect.java new file mode 100644 index 0000000..e1ab88f --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/EimsMonitoringAspect.java @@ -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; + } + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/GatewayLoggingAspect.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/GatewayLoggingAspect.java new file mode 100644 index 0000000..e1ef6eb --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/aop/GatewayLoggingAspect.java @@ -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); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ExternalApiConnector.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ExternalApiConnector.java new file mode 100644 index 0000000..97d3dd7 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ExternalApiConnector.java @@ -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 data, List> 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 data, List> spec, boolean isFixedLength, Throwable t) { + log.error(" [외부 API 장애] {} 호출 실패: {}", apiName, t.getMessage()); + return String.format("{\"status\":\"EXTERNAL_API_ERROR\", \"message\":\"외부 서비스 연동 중 오류 발생: %s\"}", t.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/InternalSystemConnector.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/InternalSystemConnector.java new file mode 100644 index 0000000..511061d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/InternalSystemConnector.java @@ -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 data, List> 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 data, List> spec, boolean isFixedLength, Throwable t) { + log.error(" [대내외 시스템 장애/지연] {} 연계 실패: {}", targetName, t.getMessage()); + return String.format("{\"status\":\"INTERNAL_SYSTEM_ERROR\", \"message\":\"대내외 연계 시스템 호출 중 오류가 발생했거나 요청이 지연되었습니다. 사유: %s\"}", t.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyDbConnector.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyDbConnector.java new file mode 100644 index 0000000..3f47074 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyDbConnector.java @@ -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> executeDynamicQuery(String queryId, String sql, Map params) { + log.info("🗄 Legacy DB 조회 시작 [QueryID: {}]", queryId); + + // 1. SQL 쿼리 실행 (오라클 등) + List> rawResults = jdbcTemplate.queryForList(sql, params); + + // 2. 스키마 매핑 및 결과 정형화 (대문자 -> 카멜케이스 변환) + List> standardResults = rawResults.stream() + .map(resultStandardizer::standardize) + .collect(Collectors.toList()); + + log.info(" Legacy DB 조회 완료 ({}건 반환)", standardResults.size()); + return standardResults; + } + + public List> fallbackForDb(String queryId, String sql, Map params, Throwable t) { + log.error(" [Legacy DB 장애/지연] 쿼리 실행 실패 [{}]: {}", queryId, t.getMessage()); + throw new RuntimeException("레거시 DB 연동 중 오류가 발생했습니다. (QueryID: " + queryId + ")"); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyEimsConnector.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyEimsConnector.java new file mode 100644 index 0000000..c76193c --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/LegacyEimsConnector.java @@ -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 data, List> 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 data, List> 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 data, List> spec) { + // 3번 항목 적용: 원본 데이터를 스펙에 맞게 엄격히 정제(변환, 형변환, 잘라내기, 기본값 등) + Map 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 "{}"; + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ThirdPartySecurityConnector.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ThirdPartySecurityConnector.java new file mode 100644 index 0000000..7a636d7 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/connector/ThirdPartySecurityConnector.java @@ -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 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; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/controller/MockEsbController.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/controller/MockEsbController.java new file mode 100644 index 0000000..4aeb805 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/controller/MockEsbController.java @@ -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 "
SUCCESS
정상 조회되었습니다.
"; + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/ErrorDetail.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/ErrorDetail.java new file mode 100644 index 0000000..045d659 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/ErrorDetail.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcRequest.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcRequest.java new file mode 100644 index 0000000..0e528fc --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcRequest.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcResponse.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcResponse.java new file mode 100644 index 0000000..293f4bb --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/JsonRpcResponse.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/Params.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/Params.java new file mode 100644 index 0000000..ec96f87 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/dto/Params.java @@ -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 data; + private List> spec; +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EaiEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EaiEimsSender.java new file mode 100644 index 0000000..5796a5d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EaiEimsSender.java @@ -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 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\n" + + "\n" + + "
\n" + + " MCP_GATEWAY_ASYNC\n" + + " EAI_BATCH_JOB\n" + + " 1782705901850\n" + + " ASYNC\n" + + "
\n" + + " \n" + + " \n" + + " BATCH_20260629_001\n" + + " GLOBAL_MINIMUM_TAX_SYS\n" + + " 50000\n" + + " \n" + + " \n" + + "
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( + "" + + "
" + + "MCP_GATEWAY_ASYNC" + + "%s" + + "%d" + + "ASYNC" + + "
" + + "%s" + + "
", + interfaceId, System.currentTimeMillis(), xmlData + ); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EimsSender.java new file mode 100644 index 0000000..96beced --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/EimsSender.java @@ -0,0 +1,6 @@ +package io.shinhanlife.axhub.biz.mcp.adapter.sender; + +public interface EimsSender { + // 프로토콜에 상관없이 이 메서드 하나로 통일합니다. + String send(String interfaceId, String payload) throws Exception; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/HttpEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/HttpEimsSender.java new file mode 100644 index 0000000..8cc5310 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/HttpEimsSender.java @@ -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 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으로 받음 + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspFormEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspFormEimsSender.java new file mode 100644 index 0000000..7253f96 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspFormEimsSender.java @@ -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
태그 전송과 동일한 효과) + MultiValueMap 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() : ""; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspJsonEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspJsonEimsSender.java new file mode 100644 index 0000000..65b8254 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/JspJsonEimsSender.java @@ -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 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() : ""; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/MciEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/MciEimsSender.java new file mode 100644 index 0000000..626408a --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/MciEimsSender.java @@ -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("
%s
%s
", interfaceId, xmlData); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/TcpEimsSender.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/TcpEimsSender.java new file mode 100644 index 0000000..bdc63f0 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/sender/TcpEimsSender.java @@ -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"); + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicPayloadBuilder.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicPayloadBuilder.java new file mode 100644 index 0000000..824df7c --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicPayloadBuilder.java @@ -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> specList, Map data) throws Exception { + StringBuilder sb = new StringBuilder(); + + for (Map 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(); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicSchemaValidator.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicSchemaValidator.java new file mode 100644 index 0000000..1cf11d8 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/DynamicSchemaValidator.java @@ -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> specList, Map data, StringBuilder errorLog) { + for (Map 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; + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ResultStandardizer.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ResultStandardizer.java new file mode 100644 index 0000000..2585030 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ResultStandardizer.java @@ -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 standardize(Map rawData) { + Map 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(); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/TicketManager.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/TicketManager.java new file mode 100644 index 0000000..c4b940e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/TicketManager.java @@ -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 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; + } + } +} + diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ToolExecutionService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ToolExecutionService.java new file mode 100644 index 0000000..55637d6 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/support/ToolExecutionService.java @@ -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 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; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsHttpServer.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsHttpServer.java new file mode 100644 index 0000000..2f58be2 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsHttpServer.java @@ -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 mockEimsReceiver( + @RequestHeader(value = "X-Trace-Id", required = false) String traceId, + @RequestBody Map 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 mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class); + + if (mockDataMap.containsKey(interfaceId)) { + return (Map) 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 "SUCCESSMOCK_MCI_EIMS_RECEIVE_SUCCESS정상 처리되었습니다."; + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsTcpServer.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsTcpServer.java new file mode 100644 index 0000000..3f76f6e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockEimsTcpServer.java @@ -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 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) {} + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockJspServer.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockJspServer.java new file mode 100644 index 0000000..e6ab2ec --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/test/MockJspServer.java @@ -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 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"; + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/LegacyDataTransformer.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/LegacyDataTransformer.java new file mode 100644 index 0000000..46e6af9 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/LegacyDataTransformer.java @@ -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 transform(Map data, List> spec) { + if (spec == null || spec.isEmpty()) { + return data != null ? data : new HashMap<>(); + } + + Map transformedData = new HashMap<>(); + + for (Map 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; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingLogbackConverter.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingLogbackConverter.java new file mode 100644 index 0000000..d13c2c9 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingLogbackConverter.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingUtils.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingUtils.java new file mode 100644 index 0000000..3b11569 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/adapter/util/PiiMaskingUtils.java @@ -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; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/client/ToolClient.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/client/ToolClient.java new file mode 100644 index 0000000..672c48d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/client/ToolClient.java @@ -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 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> supplier = () -> { + if (plan.getEndpoint() != null) { + Map targetMap = (Map) 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>() {}); + } 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> rateLimitedSupplier = RateLimiter.decorateSupplier(rateLimiter, supplier); + Supplier> retryingSupplier = Retry.decorateSupplier(retry, rateLimitedSupplier); + Supplier> protectedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, retryingSupplier); + + Map 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); + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/RedisConfig.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/RedisConfig.java new file mode 100644 index 0000000..c480a9e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/RedisConfig.java @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 1. Key는 무조건 String + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음) + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class); + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/KillSwitchController.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/KillSwitchController.java new file mode 100644 index 0000000..572efd3 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/KillSwitchController.java @@ -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 + )); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/McpRouterController.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/McpRouterController.java new file mode 100644 index 0000000..44509b4 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/controller/McpRouterController.java @@ -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> routeToPreBuildTool(@RequestBody Map 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 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 callTool(@RequestBody Map 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 listTools() { + List 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 registerTool(@RequestBody ToolMetadata meta) { + redisRegistryService.saveTool(meta); + return ResponseEntity.ok("Registered"); + } + + @PostMapping("/registry/deregister") + public ResponseEntity deregisterTool(@RequestBody String toolName) { + redisRegistryService.removeTool(toolName); + return ResponseEntity.ok("Deregistered"); + } + + @PostMapping("/registry/heartbeat") + public ResponseEntity heartbeat(@RequestBody String toolName) { + redisRegistryService.refreshHeartbeat(toolName); + return ResponseEntity.ok("Heartbeat updated"); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java new file mode 100644 index 0000000..00693fe --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java @@ -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 parametersSchema; + + // 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨) + private Map 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초당 허용 최대 요청 수 +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/messaging/KafkaProducerService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/messaging/KafkaProducerService.java new file mode 100644 index 0000000..cb136e2 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/messaging/KafkaProducerService.java @@ -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 kafkaTemplate; + private final ObjectMapper jsonMapper; + + /** + * 트래픽 초과 등의 이유로 즉시 처리가 불가능한 요청을 Kafka 대기열로 보냅니다. + * @param topic 발행할 Kafka 토픽명 + * @param payload 원본 요청 페이로드 + * @return 발급된 고유 티켓 ID + */ + public String queueRequest(String topic, Map 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; + } +} + diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/registry/RedisRegistryService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/registry/RedisRegistryService.java new file mode 100644 index 0000000..1771ff3 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/registry/RedisRegistryService.java @@ -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 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 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 getAllTools() { + Set keys = redisTemplate.keys(KEY_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return List.of(); + } + List 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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/RequestValidator.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/RequestValidator.java new file mode 100644 index 0000000..a61f0ff --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/RequestValidator.java @@ -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 REQUIRED_FIELDS = List.of("traceId", "agentId", "userPrompt"); + + public void validate(Map 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 라이브러리를 도입하여 이 부분을 교체할 수 있습니다. + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ResponseAggregator.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ResponseAggregator.java new file mode 100644 index 0000000..1a56e1e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ResponseAggregator.java @@ -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; // 최종 응답 반환 + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ToolRouter.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ToolRouter.java new file mode 100644 index 0000000..b38d2fe --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/router/ToolRouter.java @@ -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 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 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 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 availablePods) { + if (availablePods.size() == 1) { + return availablePods.get(0); + } + // Java ThreadLocalRandom을 사용하여 동시성 이슈 없이 난수 생성 + int randomIndex = ThreadLocalRandom.current().nextInt(availablePods.size()); + return availablePods.get(randomIndex); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java new file mode 100644 index 0000000..3b7946e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/KillSwitchService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/KillSwitchService.java new file mode 100644 index 0000000..337fa07 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/KillSwitchService.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ToolPlanner.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ToolPlanner.java new file mode 100644 index 0000000..d3495f0 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ToolPlanner.java @@ -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 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 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; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpFunction.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpFunction.java new file mode 100644 index 0000000..d4a92a4 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpFunction.java @@ -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 "{}"; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpParameter.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpParameter.java new file mode 100644 index 0000000..f92be89 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpParameter.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpTool.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpTool.java new file mode 100644 index 0000000..b671e83 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/annotation/McpTool.java @@ -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"; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/controller/BusinessToolController.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/controller/BusinessToolController.java new file mode 100644 index 0000000..cd0857a --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/controller/BusinessToolController.java @@ -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 executeDynamicTool(@PathVariable String toolName, @RequestBody(required = false) Map 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 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 errors = schema.validate(objectMapper.valueToTree(payload)); + if (!errors.isEmpty()) { + log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors); + Map error = new HashMap<>(); + error.put("status", "ERROR"); + List 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 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 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 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 resultMap = (Map) methodResult; + resultMap.put("ai_insight", "다이렉트 메서드(" + targetMethod.getName() + ") 통신이 성공적으로 수행되었습니다."); + return resultMap; + } else { + Map 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 error = new HashMap<>(); + error.put("status", "ERROR"); + error.put("message", "메서드 실행 중 예외 발생: " + e.getCause().getMessage()); + return error; + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingProcessReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingProcessReq.java new file mode 100644 index 0000000..9b17011 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingProcessReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingStatusReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingStatusReq.java new file mode 100644 index 0000000..1e84279 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BillingStatusReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondCheckReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondCheckReq.java new file mode 100644 index 0000000..41da399 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondCheckReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondIssueReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondIssueReq.java new file mode 100644 index 0000000..6284900 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/BondIssueReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractDetailReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractDetailReq.java new file mode 100644 index 0000000..2c5705c --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractDetailReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractStatusReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractStatusReq.java new file mode 100644 index 0000000..826cf1d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/ContractStatusReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerDetailReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerDetailReq.java new file mode 100644 index 0000000..f1ef635 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerDetailReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerGradeReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerGradeReq.java new file mode 100644 index 0000000..8a3b87c --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/CustomerGradeReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/EmailSendReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/EmailSendReq.java new file mode 100644 index 0000000..6de9850 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/EmailSendReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/LeaveCountReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/LeaveCountReq.java new file mode 100644 index 0000000..800240a --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/LeaveCountReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/SmsSendReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/SmsSendReq.java new file mode 100644 index 0000000..e807eb6 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/SmsSendReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/VacationRegisterReq.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/VacationRegisterReq.java new file mode 100644 index 0000000..39bee1e --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/VacationRegisterReq.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolConfig.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolConfig.java new file mode 100644 index 0000000..dd19c85 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolConfig.java @@ -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 functions; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolFunction.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolFunction.java new file mode 100644 index 0000000..85fa082 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/model/ToolFunction.java @@ -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; +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/registry/MockToolAutoRegistrar.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/registry/MockToolAutoRegistrar.java new file mode 100644 index 0000000..85869b4 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/registry/MockToolAutoRegistrar.java @@ -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 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 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 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 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 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 actionPrompts = new HashMap<>(); + List actions = new ArrayList<>(); + List actionDescs = new ArrayList<>(); + Map 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 autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType); + aggregatedProperties.putAll(autoSchema); + } else { + // 기존 하드코딩 스키마 사용 + String schemaJson = mcpFunc.parameterSchema(); + if (!"{}".equals(schemaJson)) { + try { + @SuppressWarnings("unchecked") + Map 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 parametersSchema = new HashMap<>(); + parametersSchema.put("type", "object"); + Map properties = new HashMap<>(); + properties.putAll(aggregatedProperties); + + Map 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()); + } + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/AbstractMcpToolService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/AbstractMcpToolService.java new file mode 100644 index 0000000..b2147e3 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/AbstractMcpToolService.java @@ -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 executeLegacy(String routingType, String interfaceId, Object inputData) { + Map inputMap; + if (inputData == null) { + inputMap = new HashMap<>(); + } else if (inputData instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) inputData; + inputMap = map; + } else { + inputMap = objectMapper.convertValue(inputData, new TypeReference>() {}); + } + + try { + // 1. Adapter 공통 모듈 직접 호출 + String executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, inputMap, null); + + // 2. PII 마스킹 처리 + String maskedResult = PiiMaskingUtils.mask(executionResult); + + Map result = new HashMap<>(); + result.put("status", "SUCCESS"); + result.put("legacy_response", maskedResult); + return result; + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("status", "ERROR"); + error.put("message", e.getMessage()); + return error; + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BillingProcessService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BillingProcessService.java new file mode 100644 index 0000000..8a63755 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BillingProcessService.java @@ -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 payload; + if (data == null) { + payload = new HashMap<>(); + } else { + ObjectMapper mapper = new ObjectMapper(); + payload = mapper.convertValue(data, new TypeReference>() {}); + } + + // 커스텀 전처리 + payload.put("custom_injected_data", "Billing System Check OK"); + log.info("💰 [Billing] 커스텀 파라미터 주입 완료"); + + // 부모 클래스의 레거시 공통 연동 메서드 호출 (PII 마스킹 포함) + Map result = executeLegacy("MCI", mappingId, payload); + + // 커스텀 후처리 + if ("SUCCESS".equals(result.get("status"))) { + result.put("billing_custom_insight", "청구 특화 후처리 로직이 적용되었습니다."); + } + return result; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BondIssueService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BondIssueService.java new file mode 100644 index 0000000..c6858c2 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/BondIssueService.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CommonUtilityService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CommonUtilityService.java new file mode 100644 index 0000000..87e330d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CommonUtilityService.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/ContractInquiryService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/ContractInquiryService.java new file mode 100644 index 0000000..13c4a8b --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/ContractInquiryService.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CustomerInfoService.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CustomerInfoService.java new file mode 100644 index 0000000..533be55 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/CustomerInfoService.java @@ -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); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/JsonSchemaGenerator.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/JsonSchemaGenerator.java new file mode 100644 index 0000000..a27502a --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/JsonSchemaGenerator.java @@ -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 generatePropertiesSchema(Class clazz) { + Map properties = new HashMap<>(); + + for (Field field : clazz.getDeclaredFields()) { + Map 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"; + } +} diff --git a/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/ToolScaffolder.java b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/ToolScaffolder.java new file mode 100644 index 0000000..a23a551 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/ToolScaffolder.java @@ -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에 추가하세요."); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/config/CacheConfig.java b/src/main/java/io/shinhanlife/axhub/common/mcp/config/CacheConfig.java new file mode 100644 index 0000000..37af12b --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/config/CacheConfig.java @@ -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"); // 아까 설정한 캐시 이름 등록 + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/config/JacksonConfig.java b/src/main/java/io/shinhanlife/axhub/common/mcp/config/JacksonConfig.java new file mode 100644 index 0000000..a4405cc --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/config/JacksonConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/config/KafkaLocalConfig.java b/src/main/java/io/shinhanlife/axhub/common/mcp/config/KafkaLocalConfig.java new file mode 100644 index 0000000..23e2c6b --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/config/KafkaLocalConfig.java @@ -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 producerFactory() { + Map 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 kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/config/SwaggerConfig.java b/src/main/java/io/shinhanlife/axhub/common/mcp/config/SwaggerConfig.java new file mode 100644 index 0000000..e6cf221 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/config/SwaggerConfig.java @@ -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)"))); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/config/WebConfig.java b/src/main/java/io/shinhanlife/axhub/common/mcp/config/WebConfig.java new file mode 100644 index 0000000..a45596a --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/config/WebConfig.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/exception/GlobalExceptionHandler.java b/src/main/java/io/shinhanlife/axhub/common/mcp/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a9f6e2d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/exception/GlobalExceptionHandler.java @@ -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 handleIllegalArgument(IllegalArgumentException e) { + log.warn(" [Gateway Bad Request] 잘못된 요청: {}", e.getMessage()); + return buildErrorResponse(-32602, "Invalid params: " + e.getMessage()); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntime(RuntimeException e) { + log.error(" [Gateway Internal Error] 시스템 장애: {}", e.getMessage(), e); + return buildErrorResponse(-32603, "Internal error: " + e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllException(Exception e) { + log.error("💥 [Gateway Fatal Error] 치명적 오류 발생", e); + return buildErrorResponse(-32000, "Server error: 시스템 관리자에게 문의하세요."); + } + + private ResponseEntity buildErrorResponse(int code, String message) { + JsonRpcResponse response = new JsonRpcResponse(); + response.setError(new ErrorDetail(code, message)); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/filter/MdcLoggingFilter.java b/src/main/java/io/shinhanlife/axhub/common/mcp/filter/MdcLoggingFilter.java new file mode 100644 index 0000000..5ff17e8 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/filter/MdcLoggingFilter.java @@ -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(); + } + } +} diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/security/ApiKeyInterceptor.java b/src/main/java/io/shinhanlife/axhub/common/mcp/security/ApiKeyInterceptor.java new file mode 100644 index 0000000..7899b5d --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/security/ApiKeyInterceptor.java @@ -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 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"); + } +} diff --git a/src/main/java/io/shinhanlife/axhub/common/mcp/security/SecurityProperties.java b/src/main/java/io/shinhanlife/axhub/common/mcp/security/SecurityProperties.java new file mode 100644 index 0000000..27fe1b1 --- /dev/null +++ b/src/main/java/io/shinhanlife/axhub/common/mcp/security/SecurityProperties.java @@ -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 apiKeys = new HashMap<>(); + + // Tenant ID를 Key로, 허용된 도메인 그룹 목록을 Value로 가지는 맵 (ex. mcp-client-1 -> [CUSTOMER, COMMON]) + // 만약 "ALL" 이 포함되어 있다면 모든 도메인에 접근 허용 + private Map> tenantDomains = new HashMap<>(); +} +