diff --git a/README.md b/README.md index f04a713f6..41bf675de 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,18 @@ DATMS ```powershell $headers = @{ 'X-Tool-Server-API-Key' = $env:TOOL_SERVER_API_KEY - 'guid' = 'guid-local-001' - 'x-request-id' = 'request-local-001' - 'employee-no' = '100001' - 'virtual-employee-no' = 'V100001' + 'X-Guid' = 'guid-local-001' + 'X-Praf-No' = '100001' + 'X-Request-Id' = 'request-local-001' + 'X-Request-Time' = '2026-08-25T12:34:56+09:00' + 'X-Vrtl-Praf-No' = 'V100001' + 'X-App-Code' = 'DATMT' + 'X-Project-Code' = 'AXHUB' + 'X-User-Ip' = '10.0.0.10' + 'X-Caller-Ip' = '10.0.0.20' + 'X-Caller-Host' = 'caller.example.internal' + 'X-Channel' = 'MCP' + 'X-Agent-Id' = 'agent-local-001' 'mcp-session-id' = 'session-local-001' } @@ -120,14 +128,24 @@ DATMS가 DATMT Tool Service를 호출할 때 사용하는 헤더는 다음과 | 헤더 | 필수 여부 | 용도 | 전달 동작 | |---|---|---|---| | `X-Tool-Server-API-Key` | 인증 설정 시 필수 | DATMS와 DATMT 사이의 Tool Server 인증 | `mcp.security.api-key` 또는 `api-keys`와 비교 | -| `guid` | 선택 | 업무 호출 상관관계 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 | -| `x-request-id` | 선택 | 요청 추적 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 | -| `employee-no` | 선택 | 실제 사용자 사번 | 하위 HTTP 호출로 전달 | -| `virtual-employee-no` | 선택 | 가상 사용자 사번 | 하위 HTTP 호출로 전달 | +| `X-Guid` | 선택 | 업무 호출 상관관계 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 | +| `X-Praf-No` | 선택 | 실제 사용자 사번 | 세션 조회와 하위 HTTP 호출로 전달 | +| `X-Request-Id` | 선택 | 요청 추적 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 | +| `X-Request-Time` | 선택 | 요청 발생 시각 | 하위 HTTP 호출로 전달 | +| `X-Vrtl-Praf-No` | 선택 | 가상 사용자 사번 | 하위 HTTP 호출로 전달 | +| `X-App-Code` | 선택 | 호출 애플리케이션 코드 | 하위 HTTP 호출로 전달 | +| `X-Project-Code` | 선택 | 호출 프로젝트 코드 | 하위 HTTP 호출로 전달 | +| `X-User-Ip` | 선택 | 사용자 IP 주소 | 하위 HTTP 호출로 전달 | +| `X-Caller-Ip` | 선택 | 호출 시스템 IP 주소 | 하위 HTTP 호출로 전달 | +| `X-Caller-Host` | 선택 | 호출 시스템 호스트명 | 하위 HTTP 호출로 전달 | +| `X-Channel` | 선택 | 호출 채널 | 하위 HTTP 호출로 전달 | +| `X-Agent-Id` | 선택 | 호출 Agent 식별자 | 하위 HTTP 호출로 전달 | | `mcp-session-id` | 선택 | MCP 세션 식별자 | 성공 응답과 하위 HTTP 호출로 전달 | REST 경로 `/mcp/{toolName}`은 Controller가 위 헤더를 직접 읽습니다. MCP Streamable HTTP 경로 `/mcp`는 `McpRequestHeaderFilter`가 동일한 헤더를 `McpRequestHeaderContext`에 저장한 뒤 Tool 실행과 하위 HTTP 호출에 전달합니다. 헤더가 없는 하위 HTTP 호출에는 `X-ANONYMOUS-REQ: AXHUB-TOOL`이 설정됩니다. +기존 `guid`, `employee-no`, `virtual-employee-no` 헤더는 지원하지 않습니다. + 주요 실행 응답은 다음과 같습니다. | HTTP 상태 | 코드 | 의미 | diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServer.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServer.java index 4a27733f2..91a8d5325 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServer.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServer.java @@ -1,7 +1,10 @@ package io.shinhanlife.dat.lib.adapter.test; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,10 +42,13 @@ public class MockEimsHttpServer { if (!resource.exists()) { return ResponseEntity.notFound().build(); } - try { + try (InputStream inputStream = resource.getInputStream()) { log.info("[MockEimsHttpServer] HTTP mock request. toolName={}, body={}", toolName, request); - return ResponseEntity.ok(objectMapper.readTree(resource.getInputStream())); - } catch (Exception e) { + return ResponseEntity.ok(objectMapper.readTree(inputStream)); + } catch (JsonProcessingException e) { + log.warn("[MockEimsHttpServer] Invalid mock response JSON. toolName={}", toolName, e); + return ResponseEntity.internalServerError().build(); + } catch (IOException e) { log.warn("[MockEimsHttpServer] Unable to read mock response. toolName={}", toolName, e); return ResponseEntity.internalServerError().build(); } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspect.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspect.java index 3b73ed6d1..59328dc5c 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspect.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspect.java @@ -67,7 +67,7 @@ public class ToolSlaMonitoringAspect { return result; - } catch (Throwable e) { + } catch (RuntimeException e) { if (stopWatch.isRunning()) { stopWatch.stop(); } @@ -80,4 +80,4 @@ public class ToolSlaMonitoringAspect { throw e; } } -} \ No newline at end of file +} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java index 6473f9c0d..1f7cb328c 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java @@ -92,11 +92,19 @@ public class AxhubHttpComponent { if (inbound == null) { header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST); } else { - putIfPresent(header, "x-request-id", inbound.requestId()); - putIfPresent(header, "guid", inbound.guid()); + putIfPresent(header, "X-Guid", inbound.guid()); + putIfPresent(header, "X-Praf-No", inbound.prafNo()); + putIfPresent(header, "X-Request-Id", inbound.requestId()); + putIfPresent(header, "X-Request-Time", inbound.requestTime()); + putIfPresent(header, "X-Vrtl-Praf-No", inbound.vrtlPrafNo()); + putIfPresent(header, "X-App-Code", inbound.appCode()); + putIfPresent(header, "X-Project-Code", inbound.projectCode()); + putIfPresent(header, "X-User-Ip", inbound.userIp()); + putIfPresent(header, "X-Caller-Ip", inbound.callerIp()); + putIfPresent(header, "X-Caller-Host", inbound.callerHost()); + putIfPresent(header, "X-Channel", inbound.channel()); + putIfPresent(header, "X-Agent-Id", inbound.agentId()); putIfPresent(header, "mcp-session-id", inbound.mcpSessionId()); - putIfPresent(header, "employee-no", inbound.employeeNo()); - putIfPresent(header, "virtual-employee-no", inbound.virtualEmployeeNo()); } header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout); return header; diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponent.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponent.java index 2cd96d398..13f82ca96 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponent.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponent.java @@ -3,7 +3,6 @@ package io.shinhanlife.dat.lib.integration.mci.component; import io.shinhanlife.dat.lib.session.dto.SessionDto; import io.shinhanlife.dat.lib.util.SessionUtil; import io.shinhanlife.dat.lib.config.GlowCommunicationProperties; -import io.shinhanlife.glow.BizException; import io.shinhanlife.glow.communication.dto.CommonHeader; import io.shinhanlife.glow.communication.dto.HeaderDefaults; import io.shinhanlife.glow.communication.dto.Transfer; @@ -91,12 +90,7 @@ public class AxhubMciComponent { CommonHeader reqHeader = (CommonHeader) request.getHeader(); log.info("[AxhubMciComponent] {} MCI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId()); - Transfer response; - try { - response = (Transfer) mci.sync(request); - } catch (RuntimeException e) { - throw new BizException("CST00477", new String[]{"대내 MCI 호출 결과 처리중 오류가 발생했습니다."}, e); - } + Transfer response = (Transfer) mci.sync(request); log.info("[AxhubMciComponent] {} MCI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId()); @@ -109,7 +103,7 @@ public class AxhubMciComponent { return response; } - public Transfer callTo(String itrfName, String rcvSvcId, I inputDto) throws Exception { + public Transfer callTo(String itrfName, String rcvSvcId, I inputDto) { Map commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId); CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap); @@ -132,7 +126,7 @@ public class AxhubMciComponent { * @param inputDto 제너릭 */ @SuppressWarnings("unchecked") - public Transfer callTo(String itrfName, String rcvSvcId, I inputDto, Class resBodyClass) throws Exception { + public Transfer callTo(String itrfName, String rcvSvcId, I inputDto, Class resBodyClass) { Map commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId); CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap); @@ -153,9 +147,8 @@ public class AxhubMciComponent { * @return Transfer * @param resBodyClass 제너릭 * @param inputDto 제너릭 - * @throws Exception Exception */ - public Transfer callTo(String itrfName, I inputDTO, Class resBodyClass) throws Exception { + public Transfer callTo(String itrfName, I inputDTO, Class resBodyClass) { String className = inputDTO.getClass().getSimpleName(); String rcvSvcId = className.replace("_I", ""); return callTo(itrfName, rcvSvcId, inputDTO, resBodyClass); @@ -168,9 +161,8 @@ public class AxhubMciComponent { * @return Transfer * @param resBodyClass 제너릭 * @param inputDto 제너릭 - * @throws Exception Exception */ - public Transfer callTo(String itrfName, I inputDTO) throws Exception { + public Transfer callTo(String itrfName, I inputDTO) { String className = inputDTO.getClass().getSimpleName(); String rcvSvcId = className.replace("_I", ""); return callTo(itrfName, rcvSvcId, inputDTO); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java index dcc7e0595..903a0cafe 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java @@ -10,7 +10,7 @@ import io.shinhanlife.dat.lib.session.mci.AxhubSessionService; import io.shinhanlife.dat.lib.session.dto.SessionDto; import org.springframework.beans.factory.annotation.Autowired; -/** Captures optional correlation and employee headers for an MCP HTTP call and creates mock session. */ +/** Captures optional Tool request headers for an MCP HTTP call and creates the user session. */ @Component public class McpRequestHeaderFilter implements Filter { @@ -29,18 +29,26 @@ public class McpRequestHeaderFilter implements Filter { return; } - String empNo = request.getHeader("employee-no"); + String prafNo = request.getHeader("X-Praf-No"); McpRequestHeaderContext.set(new McpRequestHeaders( - request.getHeader("x-request-id"), - request.getHeader("guid"), - request.getHeader("mcp-session-id"), - empNo, - request.getHeader("virtual-employee-no"))); + request.getHeader("X-Guid"), + prafNo, + request.getHeader("X-Request-Id"), + request.getHeader("X-Request-Time"), + request.getHeader("X-Vrtl-Praf-No"), + request.getHeader("X-App-Code"), + request.getHeader("X-Project-Code"), + request.getHeader("X-User-Ip"), + request.getHeader("X-Caller-Ip"), + request.getHeader("X-Caller-Host"), + request.getHeader("X-Channel"), + request.getHeader("X-Agent-Id"), + request.getHeader("mcp-session-id"))); - // Fetch session info via MCI using employee-no (Only if sessionService is available, e.g. in Tool Pods) - if (sessionService != null && empNo != null && !empNo.isEmpty()) { - SessionDto sessionDto = sessionService.fetchUserSession(empNo); + // Fetch session info via MCI using X-Praf-No (only when the service is available in a Tool Pod). + if (sessionService != null && prafNo != null && !prafNo.isEmpty()) { + SessionDto sessionDto = sessionService.fetchUserSession(prafNo); if (sessionDto != null) { // Set to request attribute so SessionUtil can find it request.setAttribute("userInfo", sessionDto); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java index 7f452d8cd..693976916 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java @@ -2,9 +2,17 @@ package io.shinhanlife.dat.lib.mcp; /** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */ public record McpRequestHeaders( - String requestId, String guid, - String mcpSessionId, - String employeeNo, - String virtualEmployeeNo) { + String prafNo, + String requestId, + String requestTime, + String vrtlPrafNo, + String appCode, + String projectCode, + String userIp, + String callerIp, + String callerHost, + String channel, + String agentId, + String mcpSessionId) { } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java index 6921b3109..af83630f4 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java @@ -3,7 +3,10 @@ package io.shinhanlife.dat.lib.mcp; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; import io.shinhanlife.dat.lib.util.ToolSchemaResolver; +import io.shinhanlife.dat.lib.util.ToolSchemaResolver.ToolSchemaResourceException; import io.shinhanlife.dat.lib.validation.ToolArgumentSchemaValidator; +import io.shinhanlife.glow.BizException; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; @@ -27,7 +30,7 @@ public class McpToolExecutionService { String requestId = requestHeaders == null ? null : requestHeaders.requestId(); String guid = requestHeaders == null ? null : requestHeaders.guid(); String mcpSessionId = requestHeaders == null ? null : requestHeaders.mcpSessionId(); - log.info("[Tool] IN - guid: {}, x-request-id: {}, tool: {}", guid, requestId, functionName); + log.info("[Tool] IN - X-Guid: {}, X-Request-Id: {}, tool: {}", guid, requestId, functionName); McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName); if (resolvedTool == null) { @@ -44,12 +47,29 @@ public class McpToolExecutionService { return outputFailure; } Map headers = new HashMap<>(); - if (requestId != null) headers.put("x-request-id", requestId); - if (guid != null) headers.put("guid", guid); + if (requestId != null) headers.put("X-Request-Id", requestId); + if (guid != null) headers.put("X-Guid", guid); if (mcpSessionId != null) headers.put("mcp-session-id", mcpSessionId); - log.info("[Tool] OUT - guid: {}, x-request-id: {}, tool: {}", guid, requestId, functionName); + log.info("[Tool] OUT - X-Guid: {}, X-Request-Id: {}, tool: {}", guid, requestId, functionName); return new ToolExecutionResult(200, methodResult, headers); - } catch (Exception error) { + } catch (IllegalArgumentException error) { + log.error("[Tool] Tool argument conversion failed. tool={}", functionName, error); + return error(502, "TOOL_ERROR", "Tool execution failed", requestId); + } catch (IllegalAccessException error) { + log.error("[Tool] Tool method is not accessible. tool={}", functionName, error); + return error(502, "TOOL_ERROR", "Tool execution failed", requestId); + } catch (InvocationTargetException error) { + Throwable cause = error.getCause(); + if (cause instanceof BizException businessError) { + log.error("[Tool] Tool business execution failed. tool={}", functionName, businessError); + return error(502, "TOOL_ERROR", "Tool execution failed", requestId); + } + if (cause instanceof RuntimeException runtimeError) { + throw runtimeError; + } + if (cause instanceof Error jvmError) { + throw jvmError; + } log.error("[Tool] Tool execution failed. tool={}", functionName, error); return error(502, "TOOL_ERROR", "Tool execution failed", requestId); } @@ -62,8 +82,8 @@ public class McpToolExecutionService { Map schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]); ValidationResponse result = toolArgumentSchemaValidator.validate(schema, arguments); return result.valid() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId); - } catch (Exception error) { - log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error); + } catch (ToolSchemaResourceException error) { + log.error("[Tool] Input schema resource loading failed. tool={}", tool.annotation().name(), error); return null; } } @@ -73,7 +93,8 @@ public class McpToolExecutionService { return objectMapper.convertValue(arguments, method.getParameterTypes()[0]); } - private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) throws Exception { + private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) + throws IllegalAccessException, InvocationTargetException { return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument); } @@ -84,8 +105,8 @@ public class McpToolExecutionService { if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).valid()) { return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId); } - } catch (Exception error) { - log.error("[Tool] Output schema validation failed unexpectedly. tool={}", tool.annotation().name(), error); + } catch (ToolSchemaResourceException error) { + log.error("[Tool] Output schema resource loading failed. tool={}", tool.annotation().name(), error); } return null; } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionService.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionService.java index 9cc4eec99..68c39776f 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionService.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionService.java @@ -1,7 +1,7 @@ package io.shinhanlife.dat.lib.session.mci; -import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent; import io.shinhanlife.dat.lib.session.dto.SessionDto; +import io.shinhanlife.glow.BizException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,7 +15,7 @@ import java.util.List; @RequiredArgsConstructor public class AxhubSessionService { - private final AxhubMciComponent mciComponent; + private final MciOnbszClient mciOnbszClient; public SessionDto fetchUserSession(String employeeNo) { if (employeeNo == null || employeeNo.trim().isEmpty()) { @@ -23,18 +23,18 @@ public class AxhubSessionService { } try { - ONBSA0790_I request = ONBSA0790_I.builder() - .plnrPrafScrnInqrInDto(ONBSA0790_I.PlnrPrafScrnInqrInDto.builder() - .prafNo(employeeNo) - .build()) + ONBSZ0460_I.CmmnPrafIfinInDto input = ONBSZ0460_I.CmmnPrafIfinInDto.builder() + .prafNo(employeeNo) + .build(); + ONBSZ0460_I request = ONBSZ0460_I.builder() + .cmmnPrafIfinInDto(List.of(input)) .build(); - // callTo makes a synchronous MCI call. - var responseTransfer = mciComponent.callTo("ONBSA0790", "ONBSA", request, ONBSA0790_O.class); - ONBSA0790_O response = responseTransfer.getBody(); + ONBSZ0460_O response = mciOnbszClient.callOnbsz0460(request); - if (response != null && response.getPrafInfoDto() != null && !response.getPrafInfoDto().isEmpty()) { - ONBSA0790_O.PrafInfoDto info = response.getPrafInfoDto().get(0); + if (response != null && response.getCmmnPrafIfinOutDto() != null + && !response.getCmmnPrafIfinOutDto().isEmpty()) { + ONBSZ0460_O.CmmnPrafIfinOutDto info = response.getCmmnPrafIfinOutDto().get(0); SessionDto sessionDto = new SessionDto(); sessionDto.setOgnzNo(info.getOgnzAsrtCd()); @@ -48,11 +48,11 @@ public class AxhubSessionService { sessionDto.setOgnzAsrtCd(info.getOgnzAsrtCd()); sessionDto.setOgnzLeveCd(info.getOgnzLeveCd()); - log.info("[AxhubSessionService] MCI ONBSA0790 조회 성공: 사번={}, 조직코드={}", employeeNo, info.getOgnzAsrtCd()); + log.info("[AxhubSessionService] MCI ONBSZ0460 조회 성공: 사번={}, 조직코드={}", employeeNo, info.getOgnzAsrtCd()); return sessionDto; } - } catch (Exception e) { - log.error("[AxhubSessionService] MCI ONBSA0790 사원 정보 조회 실패 (사번: {})", employeeNo, e); + } catch (BizException e) { + log.error("[AxhubSessionService] MCI ONBSZ0460 사원 정보 조회 실패 (사번: {})", employeeNo, e); } return null; } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/MciOnbszClient.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/MciOnbszClient.java new file mode 100644 index 000000000..4041c5850 --- /dev/null +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/MciOnbszClient.java @@ -0,0 +1,22 @@ +package io.shinhanlife.dat.lib.session.mci; + +import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent; +import io.shinhanlife.glow.communication.dto.Transfer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MciOnbszClient { + + private static final String INTERFACE_ID = "ONBSZ0460"; + private static final String RECEIVE_SERVICE_ID = "ONBSZ"; + + private final AxhubMciComponent mciComponent; + + public ONBSZ0460_O callOnbsz0460(ONBSZ0460_I request) { + Transfer transfer = mciComponent.callTo( + INTERFACE_ID, RECEIVE_SERVICE_ID, request, ONBSZ0460_O.class); + return transfer == null ? null : transfer.getBody(); + } +} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_I.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_I.java deleted file mode 100644 index 567a0bcdf..000000000 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_I.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.shinhanlife.dat.lib.session.mci; - -import io.shinhanlife.glow.communication.annotation.GlowTrgmField; -import lombok.*; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ONBSA0790_I { - - @GlowTrgmField(order = 1, length = 100, description = "설계사인사화면조회입력DTO") - private PlnrPrafScrnInqrInDto plnrPrafScrnInqrInDto; - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class PlnrPrafScrnInqrInDto { - @GlowTrgmField(order = 1, length = 7, description = "인사번호") - private String prafNo; - } -} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_O.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_O.java deleted file mode 100644 index ba191e7eb..000000000 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSA0790_O.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.shinhanlife.dat.lib.session.mci; - -import io.shinhanlife.glow.communication.annotation.GlowTrgmField; -import lombok.*; -import java.util.List; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ONBSA0790_O { - - @GlowTrgmField(order = 1, length = 1000, description = "인사정보DTO리스트") - private List prafInfoDto; - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class PrafInfoDto { - @GlowTrgmField(order = 1, length = 7, description = "인사번호") - private String prafNo; - - @GlowTrgmField(order = 2, length = 2, description = "인사유형코드") - private String prafTypeCd; - - @GlowTrgmField(order = 3, length = 100, description = "인사명") - private String prafNm; - - @GlowTrgmField(order = 22, length = 3, description = "영업직책코드") - private String bsduCd; - - @GlowTrgmField(order = 28, length = 6, description = "조직분류코드") - private String ognzAsrtCd; - - @GlowTrgmField(order = 29, length = 6, description = "인사조직분류코드") - private String psmrAsrtCd; - - @GlowTrgmField(order = 30, length = 6, description = "영업규정분류코드") - private String sbsnRulpAsrtCd; - - @GlowTrgmField(order = 31, length = 6, description = "조직레벨코드") - private String ognzLeveCd; - - @GlowTrgmField(order = 32, length = 7, description = "지점번호") - private String brafNo; - - @GlowTrgmField(order = 33, length = 7, description = "영업소코드") - private String bsquCd; - } -} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_I.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_I.java new file mode 100644 index 000000000..b0db35334 --- /dev/null +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_I.java @@ -0,0 +1,45 @@ +package io.shinhanlife.dat.lib.session.mci; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.shinhanlife.glow.communication.annotation.GlowTrgmField; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ONBSZ0460_I { + + @GlowTrgmField(order = 1, description = "공통인사정보조회InDto", type = "gm") + private List cmmnPrafIfinInDto; + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CmmnPrafIfinInDto { + + @GlowTrgmField(order = 1, length = 6, description = "마감년월") + private String closYm; + + @GlowTrgmField(order = 2, length = 7, description = "조직번호") + private String ognzNo; + + @GlowTrgmField(order = 3, length = 8, description = "인사번호") + private String prafNo; + + @GlowTrgmField(order = 4, length = 3, description = "인사조직분류코드") + private String psmrAsrtCd; + + @GlowTrgmField(order = 5, length = 1, description = "트레이너구분코드") + private String trnrSccd; + } +} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_O.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_O.java new file mode 100644 index 000000000..58b0b2366 --- /dev/null +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/session/mci/ONBSZ0460_O.java @@ -0,0 +1,187 @@ +package io.shinhanlife.dat.lib.session.mci; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.shinhanlife.glow.communication.annotation.GlowTrgmField; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ONBSZ0460_O { + + @GlowTrgmField(order = 1, description = "공통인사정보조회OutDto", type = "gm") + private List cmmnPrafIfinOutDto; + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CmmnPrafIfinOutDto { + + @GlowTrgmField(order = 1, length = 8, description = "인사번호") + private String prafNo; + @GlowTrgmField(order = 2, length = 2, description = "인사유형코드") + private String prafTypeCd; + @GlowTrgmField(order = 3, length = 200, description = "인사명") + private String prafNm; + @GlowTrgmField(order = 4, length = 100, description = "인사영문명") + private String prafEngNm; + @GlowTrgmField(order = 5, length = 20, description = "생년월일") + private String birymd; + @GlowTrgmField(order = 6, length = 1, description = "성별코드") + private String gndrCd; + @GlowTrgmField(order = 7, length = 50, description = "주민등록번호") + private String rdreNo; + @GlowTrgmField(order = 8, length = 2, description = "조직분류코드") + private String ognzAsrtCd; + @GlowTrgmField(order = 9, length = 0, description = "TODO 전문 명세 매핑") + private String reserved09; + @GlowTrgmField(order = 10, length = 0, description = "TODO 전문 명세 매핑") + private String reserved10; + @GlowTrgmField(order = 11, length = 0, description = "TODO 전문 명세 매핑") + private String reserved11; + @GlowTrgmField(order = 12, length = 0, description = "TODO 전문 명세 매핑") + private String reserved12; + @GlowTrgmField(order = 13, length = 0, description = "TODO 전문 명세 매핑") + private String reserved13; + @GlowTrgmField(order = 14, length = 0, description = "위촉구분코드 - 길이 확인 필요") + private String appnSccd; + @GlowTrgmField(order = 15, length = 2, description = "위촉경력구분코드") + private String appnCareSccd; + @GlowTrgmField(order = 16, length = 2, description = "위해촉최종상태코드") + private String aprdLsstCd; + @GlowTrgmField(order = 17, length = 20, description = "위촉일자") + private String appnYmd; + @GlowTrgmField(order = 18, length = 20, description = "부임일자") + private String acsoYmd; + @GlowTrgmField(order = 19, length = 20, description = "해임일자") + private String dmssYmd; + @GlowTrgmField(order = 20, length = 20, description = "해촉일자") + private String dsapYmd; + @GlowTrgmField(order = 21, length = 20, description = "재위촉일자") + private String reapYmd; + @GlowTrgmField(order = 22, length = 3, description = "영업직책코드") + private String bsduCd; + @GlowTrgmField(order = 23, length = 10, description = "생명보험협회등록번호") + private String klirNo; + @GlowTrgmField(order = 24, length = 2, description = "신인등급코드") + private String nwfaGrdCd; + @GlowTrgmField(order = 25, length = 2, description = "영업자격코드") + private String bsquCd; + @GlowTrgmField(order = 26, length = 6, description = "자격심사년월") + private String qlfcIspaYm; + @GlowTrgmField(order = 27, length = 2, description = "인사등급코드") + private String prafGrdCd; + @GlowTrgmField(order = 28, length = 0, description = "인사조직분류코드 - 길이 확인 필요") + private String psmrAsrtCd; + @GlowTrgmField(order = 29, length = 0, description = "영업규정분류코드 - 길이 확인 필요") + private String sbsnRulpAsrtCd; + @GlowTrgmField(order = 30, length = 0, description = "조직레벨코드 - 길이 확인 필요") + private String ognzLeveCd; + @GlowTrgmField(order = 31, length = 0, description = "지점번호 - 길이 확인 필요") + private String brafNo; + @GlowTrgmField(order = 32, length = 0, description = "TODO 전문 명세 매핑") + private String reserved32; + @GlowTrgmField(order = 33, length = 0, description = "TODO 전문 명세 매핑") + private String reserved33; + @GlowTrgmField(order = 34, length = 0, description = "TODO 전문 명세 매핑") + private String reserved34; + @GlowTrgmField(order = 35, length = 0, description = "TODO 전문 명세 매핑") + private String reserved35; + @GlowTrgmField(order = 36, length = 0, description = "TODO 전문 명세 매핑") + private String reserved36; + @GlowTrgmField(order = 37, length = 0, description = "TODO 전문 명세 매핑") + private String reserved37; + @GlowTrgmField(order = 38, length = 0, description = "TODO 전문 명세 매핑") + private String reserved38; + @GlowTrgmField(order = 39, length = 0, description = "TODO 전문 명세 매핑") + private String reserved39; + @GlowTrgmField(order = 40, length = 0, description = "TODO 전문 명세 매핑") + private String reserved40; + @GlowTrgmField(order = 41, length = 0, description = "TODO 전문 명세 매핑") + private String reserved41; + @GlowTrgmField(order = 42, length = 0, description = "TODO 전문 명세 매핑") + private String reserved42; + @GlowTrgmField(order = 43, length = 0, description = "TODO 전문 명세 매핑") + private String reserved43; + @GlowTrgmField(order = 44, length = 0, description = "TODO 전문 명세 매핑") + private String reserved44; + @GlowTrgmField(order = 45, length = 0, description = "TODO 전문 명세 매핑") + private String reserved45; + @GlowTrgmField(order = 46, length = 0, description = "TODO 전문 명세 매핑") + private String reserved46; + @GlowTrgmField(order = 47, length = 0, description = "TODO 전문 명세 매핑") + private String reserved47; + @GlowTrgmField(order = 48, length = 0, description = "TODO 전문 명세 매핑") + private String reserved48; + @GlowTrgmField(order = 49, length = 0, description = "TODO 전문 명세 매핑") + private String reserved49; + @GlowTrgmField(order = 50, length = 0, description = "TODO 전문 명세 매핑") + private String reserved50; + @GlowTrgmField(order = 51, length = 0, description = "TODO 전문 명세 매핑") + private String reserved51; + @GlowTrgmField(order = 52, length = 0, description = "TODO 전문 명세 매핑") + private String reserved52; + @GlowTrgmField(order = 53, length = 0, description = "TODO 전문 명세 매핑") + private String reserved53; + @GlowTrgmField(order = 54, length = 0, description = "TODO 전문 명세 매핑") + private String reserved54; + @GlowTrgmField(order = 55, length = 0, description = "TODO 전문 명세 매핑") + private String reserved55; + @GlowTrgmField(order = 56, length = 0, description = "TODO 전문 명세 매핑") + private String reserved56; + @GlowTrgmField(order = 57, length = 0, description = "TODO 전문 명세 매핑") + private String reserved57; + @GlowTrgmField(order = 58, length = 0, description = "TODO 전문 명세 매핑") + private String reserved58; + @GlowTrgmField(order = 59, length = 0, description = "TODO 전문 명세 매핑") + private String reserved59; + @GlowTrgmField(order = 60, length = 0, description = "TODO 전문 명세 매핑") + private String reserved60; + @GlowTrgmField(order = 61, length = 0, description = "TODO 전문 명세 매핑") + private String reserved61; + @GlowTrgmField(order = 62, length = 0, description = "TODO 전문 명세 매핑") + private String reserved62; + @GlowTrgmField(order = 63, length = 0, description = "TODO 전문 명세 매핑") + private String reserved63; + @GlowTrgmField(order = 64, length = 0, description = "TODO 전문 명세 매핑") + private String reserved64; + @GlowTrgmField(order = 65, length = 0, description = "TODO 전문 명세 매핑") + private String reserved65; + @GlowTrgmField(order = 66, length = 0, description = "TODO 전문 명세 매핑") + private String reserved66; + @GlowTrgmField(order = 67, length = 0, description = "TODO 전문 명세 매핑") + private String reserved67; + @GlowTrgmField(order = 68, length = 0, description = "TODO 전문 명세 매핑") + private String reserved68; + @GlowTrgmField(order = 69, length = 0, description = "TODO 전문 명세 매핑") + private String reserved69; + @GlowTrgmField(order = 70, length = 0, description = "TODO 전문 명세 매핑") + private String reserved70; + @GlowTrgmField(order = 71, length = 0, description = "TODO 전문 명세 매핑") + private String reserved71; + @GlowTrgmField(order = 72, length = 0, description = "TODO 전문 명세 매핑") + private String reserved72; + @GlowTrgmField(order = 73, length = 0, description = "TODO 전문 명세 매핑") + private String reserved73; + @GlowTrgmField(order = 74, length = 0, description = "TODO 전문 명세 매핑") + private String reserved74; + @GlowTrgmField(order = 75, length = 0, description = "TODO 전문 명세 매핑") + private String reserved75; + @GlowTrgmField(order = 76, length = 0, description = "TODO 전문 명세 매핑") + private String reserved76; + @GlowTrgmField(order = 77, length = 0, description = "TODO 전문 명세 매핑") + private String reserved77; + @GlowTrgmField(order = 78, length = 0, description = "TODO 전문 명세 매핑") + private String reserved78; + } +} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java index e38c2b75a..4d353e519 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java @@ -15,10 +15,6 @@ public class PodScaffolder { public static void main(String[] args) throws IOException { Scanner scanner = new Scanner(System.in); - System.out.println("========================================="); - System.out.println(" MCP Tool Pod Scaffolder (Java CLI) "); - System.out.println("=========================================\n"); - String rawModuleName = getOrAsk(args, 0, scanner, "1. 생성할 모듈(Pod) 이름 (예: payment 또는 dat-was-payment): "); String moduleName = rawModuleName.startsWith("dat-was-") ? rawModuleName : "dat-was-" + rawModuleName; String portStr = getOrAsk(args, 1, scanner, "2. 사용할 포트 번호 (예: 8085): "); @@ -33,14 +29,12 @@ public class PodScaffolder { if (createDate.trim().isEmpty()) createDate = defaultDate; String result = scaffoldPod(moduleName, portStr, shortName, author, createDate); - System.out.println(result); } private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) { if (args.length > index) { return args[index]; } - System.out.print(prompt); return scanner.nextLine().trim(); } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java index f834388f1..489356993 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java @@ -187,7 +187,9 @@ public class ToolScaffolder { writeStructuredFieldTypes(ioDir, ioPackage + ".io", tool.interfaceId() + "_O", tool.outputFields()); String sysCode = tool.clientSystemCode(); if (sysCode != null && sysCode.length() == 4) { - String clientCap = toPascalCase(sysCode); + String normalizedSysCode = sysCode.toLowerCase(Locale.ROOT); + String clientCap = normalizedSysCode.substring(0, 1).toUpperCase(Locale.ROOT) + + normalizedSysCode.substring(1); writeUtf8(clientDir.resolve("Mci" + clientCap + "Client.java"), "package " + ioPackage + ";\n\nimport io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;\nimport io.shinhanlife.glow.communication.dto.Transfer;\nimport lombok.RequiredArgsConstructor;\nimport org.springframework.stereotype.Component;\n\n@Component\n@RequiredArgsConstructor\npublic class Mci" + clientCap + "Client {\n private final AxhubMciComponent mci;\n\n public Transfer callTo(String interfaceId, String dummy, Object mciReq, Class resType) throws Exception {\n return mci.callTo(interfaceId, dummy, mciReq, resType);\n }\n}\n"); } else { @@ -615,6 +617,9 @@ public class ToolScaffolder { title = title == null || title.isBlank() ? baseName : title.trim(); description = description == null ? "" : description.trim(); httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim(); + if (definitionOptions == null) { + definitionOptions = new ToolDefinitionOptions(null, null, null, null, null, null, null, null); + } String envSourceDir = System.getProperty("AXHUB_SOURCE_DIR"); if (envSourceDir == null) { envSourceDir = System.getenv("AXHUB_SOURCE_DIR"); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolSchemaResolver.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolSchemaResolver.java index 8169dbf7e..52fc19557 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolSchemaResolver.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolSchemaResolver.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dat.lib.annotation.McpOutputSchema; import io.shinhanlife.dat.lib.annotation.GrowToolHint; +import java.io.IOException; import java.io.InputStream; import java.util.Map; import org.springframework.core.io.ClassPathResource; @@ -66,13 +67,25 @@ public class ToolSchemaResolver { : location; ClassPathResource resource = new ClassPathResource(path); if (!resource.exists()) { - throw new IllegalStateException("MCP schema resource not found: " + location); + throw new ToolSchemaResourceException("MCP schema resource not found: " + location); } try (InputStream inputStream = resource.getInputStream()) { return objectMapper.readValue(inputStream, new TypeReference<>() { }); - } catch (Exception e) { - throw new IllegalStateException("Failed to load MCP schema resource: " + location, e); + } catch (IOException e) { + throw new ToolSchemaResourceException("Failed to load MCP schema resource: " + location, e); } } -} \ No newline at end of file + + public static final class ToolSchemaResourceException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public ToolSchemaResourceException(String message) { + super(message); + } + + public ToolSchemaResourceException(String message, IOException cause) { + super(message, cause); + } + } +} diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/validation/McpToolNameValidationRunner.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/validation/McpToolNameValidationRunner.java index 8f8bf510b..dbd83674a 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/validation/McpToolNameValidationRunner.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/validation/McpToolNameValidationRunner.java @@ -8,13 +8,6 @@ public final class McpToolNameValidationRunner { private McpToolNameValidationRunner() { } - public static void main(String[] args) { - if (args.length != 1) { - throw new IllegalArgumentException("Usage: McpToolNameValidationRunner "); - } - validate(Path.of(args[0])); - } - static void validate(Path projectRoot) { McpToolNameValidator.assertUnique(projectRoot); } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java index 917c0fd36..26e10607f 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java @@ -34,15 +34,24 @@ public class BusinessToolController { @PostMapping("/mcp/{name}") public ResponseEntity executeDynamicTool( @PathVariable("name") String functionName, - @RequestHeader(value = "x-request-id", required = false) String requestId, - @RequestHeader(value = "guid", required = false) String guid, + @RequestHeader(value = "X-Guid", required = false) String guid, + @RequestHeader(value = "X-Praf-No", required = false) String prafNo, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestHeader(value = "X-Request-Time", required = false) String requestTime, + @RequestHeader(value = "X-Vrtl-Praf-No", required = false) String vrtlPrafNo, + @RequestHeader(value = "X-App-Code", required = false) String appCode, + @RequestHeader(value = "X-Project-Code", required = false) String projectCode, + @RequestHeader(value = "X-User-Ip", required = false) String userIp, + @RequestHeader(value = "X-Caller-Ip", required = false) String callerIp, + @RequestHeader(value = "X-Caller-Host", required = false) String callerHost, + @RequestHeader(value = "X-Channel", required = false) String channel, + @RequestHeader(value = "X-Agent-Id", required = false) String agentId, @RequestHeader(value = "mcp-session-id", required = false) String mcpSessionId, - @RequestHeader(value = "employee-no", required = false) String employeeNo, - @RequestHeader(value = "virtual-employee-no", required = false) String virtualEmployeeNo, @RequestBody(required = false) Map arguments) { ToolExecutionResult result = toolExecutionService.execute( functionName, - new McpRequestHeaders(requestId, guid, mcpSessionId, employeeNo, virtualEmployeeNo), + new McpRequestHeaders(guid, prafNo, requestId, requestTime, vrtlPrafNo, appCode, + projectCode, userIp, callerIp, callerHost, channel, agentId, mcpSessionId), arguments); ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode()); result.headers().forEach(response::header); diff --git a/dat-was-lib/src/main/resources/static/tool-test-console.html b/dat-was-lib/src/main/resources/static/tool-test-console.html index bd5dbd370..31aa9f5ba 100644 --- a/dat-was-lib/src/main/resources/static/tool-test-console.html +++ b/dat-was-lib/src/main/resources/static/tool-test-console.html @@ -69,7 +69,7 @@

2. 요청 JSON

필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.
-

3. 실행 결과

대기-guid: -x-request-id: -
Tool을 선택하고 실행하세요.
+

3. 실행 결과

대기-X-Guid: -X-Request-Id: -
Tool을 선택하고 실행하세요.
@@ -257,7 +257,25 @@ const fallback = `/mcp/${encodeURIComponent(tool.name)}`; try { const endpoint = new URL(tool.endpoint || fallback, window.location.origin); return endpoint.origin === window.location.origin ? `${endpoint.pathname}${endpoint.search}` : fallback; } catch (_) { return fallback; } } - function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'guid: -'; $('requestId').textContent = 'x-request-id: -'; } + function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'X-Guid: -'; $('requestId').textContent = 'X-Request-Id: -'; } + function toolHeaders(guid, request, session) { + return { + 'Content-Type': 'application/json', + 'X-Guid': guid, + 'X-Praf-No': '100001', + 'X-Request-Id': request, + 'X-Request-Time': new Date().toISOString(), + 'X-Vrtl-Praf-No': 'V100001', + 'X-App-Code': 'DATMT', + 'X-Project-Code': 'AXHUB', + 'X-User-Ip': '127.0.0.1', + 'X-Caller-Ip': '127.0.0.1', + 'X-Caller-Host': window.location.hostname || 'localhost', + 'X-Channel': 'TOOL-TEST-CONSOLE', + 'X-Agent-Id': 'TOOL-TEST-CONSOLE', + 'mcp-session-id': session + }; + } async function execute(tool = state.selected, body = null) { if (!tool) throw new Error('실행할 Tool을 선택하세요.'); @@ -269,13 +287,13 @@ const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() }; response = await fetch('/mcp/api/v1/tools/call', { method: 'POST', - headers: { 'Content-Type':'application/json', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session }, + headers: toolHeaders(guid, request, session), body: JSON.stringify(reqPayload) }); } else { response = await fetch(endpointFor(tool), { method: 'POST', - headers: { 'Content-Type':'application/json', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session }, + headers: toolHeaders(guid, request, session), body: JSON.stringify(payload) }); } @@ -290,7 +308,7 @@ const elapsed = Math.round(performance.now() - started); $('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`; - $('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `guid: ${response.headers.get('guid') || guid}`; $('requestId').textContent = `x-request-id: ${response.headers.get('x-request-id') || request}`; + $('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `X-Guid: ${response.headers.get('X-Guid') || guid}`; $('requestId').textContent = `X-Request-Id: ${response.headers.get('X-Request-Id') || request}`; let displayData = data; if (isGatewayMode && data && typeof data === 'object') { if (data.result && data.result.result) { diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServerTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServerTest.java index f380c1ea6..6deab3f94 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServerTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/adapter/test/MockEimsHttpServerTest.java @@ -1,9 +1,13 @@ package io.shinhanlife.dat.lib.adapter.test; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class MockEimsHttpServerTest { @@ -16,4 +20,28 @@ class MockEimsHttpServerTest { assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); assertThat(response.getBody().path("resultCode").asText()).isEqualTo("SUCCESS"); } + + @Test + void returnsInternalServerErrorForMalformedMockJson() { + MockEimsHttpServer server = new MockEimsHttpServer(new ObjectMapper()); + + var response = server.mockToolHttpResponse("invalid_mock_response", null); + + assertThat(response.getStatusCode().value()).isEqualTo(500); + } + + @Test + void doesNotSwallowUnexpectedRuntimeExceptions() { + ObjectMapper failingMapper = new ObjectMapper() { + @Override + public JsonNode readTree(InputStream input) throws IOException { + throw new IllegalStateException("unexpected mapper failure"); + } + }; + MockEimsHttpServer server = new MockEimsHttpServer(failingMapper); + + assertThatThrownBy(() -> server.mockToolHttpResponse("cmm_memo_retriever", null)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("unexpected mapper failure"); + } } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspectTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspectTest.java new file mode 100644 index 000000000..24c0cbc97 --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/aop/ToolSlaMonitoringAspectTest.java @@ -0,0 +1,45 @@ +package io.shinhanlife.dat.lib.aop; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dat.lib.config.McpProperties; +import java.lang.reflect.Method; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; + +class ToolSlaMonitoringAspectTest { + + @Test + void propagatesJvmErrorsWithoutInspectingThem() throws Throwable { + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + MethodSignature signature = mock(MethodSignature.class); + Method method = SampleTool.class.getDeclaredMethod("execute"); + MessageAccessError error = new MessageAccessError(); + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.getMethod()).thenReturn(method); + when(joinPoint.proceed()).thenThrow(error); + + MessageAccessError thrown = assertThrows(MessageAccessError.class, + () -> new ToolSlaMonitoringAspect(new McpProperties()).monitorToolSla(joinPoint)); + + assertSame(error, thrown); + } + + static class SampleTool { + @McpTool(name = "sample_tool") + public void execute() { + } + } + + static class MessageAccessError extends Error { + @Override + public String getMessage() { + throw new AssertionError("JVM errors must not be inspected by the SLA aspect"); + } + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java index 0127bd044..c5e5e0466 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java @@ -69,7 +69,7 @@ class AxhubHttpComponentTest { } @Test - void forwardsDapmsHeadersToTheConfiguredHttpService() throws Exception { + void forwardsCanonicalToolHeadersToTheConfiguredHttpService() throws Exception { RestClient.Builder builder = RestClient.builder(); MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); AxhubHttpProperties properties = new AxhubHttpProperties(); @@ -79,22 +79,35 @@ class AxhubHttpComponentTest { new GlowHttpComponent(builder), new ObjectMapper(), new GlowCommunicationProperties(), properties); server.expect(requestTo("https://api.example.test/v1")) - .andExpect(header("x-request-id", "request-1")) - .andExpect(header("guid", "guid-1")) + .andExpect(header("X-Guid", "guid-1")) + .andExpect(header("X-Praf-No", "praf-1")) + .andExpect(header("X-Request-Id", "request-1")) + .andExpect(header("X-Request-Time", "2026-08-25T12:34:56+09:00")) + .andExpect(header("X-Vrtl-Praf-No", "virtual-1")) + .andExpect(header("X-App-Code", "app-1")) + .andExpect(header("X-Project-Code", "project-1")) + .andExpect(header("X-User-Ip", "10.0.0.1")) + .andExpect(header("X-Caller-Ip", "10.0.0.2")) + .andExpect(header("X-Caller-Host", "caller.example.test")) + .andExpect(header("X-Channel", "MCP")) + .andExpect(header("X-Agent-Id", "agent-1")) .andExpect(header("mcp-session-id", "session-1")) - .andExpect(header("employee-no", "ENC(employee)")) - .andExpect(header("virtual-employee-no", "ENC(virtual)")) .andRespond(withSuccess("{\"status\":\"OK\"}", APPLICATION_JSON)); - setRequestHeaders(headers(Map.of( - "requestId", "request-1", - "guid", "guid-1", - "mcpSessionId", "session-1", - "employeeNo", "ENC(employee)", - "virtualEmployeeNo", "ENC(virtual)", - "headerRequestId", "request-1", - "traceId", "guid-1", - "encryptedEmployeeId", "ENC(employee)"))); + setRequestHeaders(headers(Map.ofEntries( + Map.entry("guid", "guid-1"), + Map.entry("prafNo", "praf-1"), + Map.entry("requestId", "request-1"), + Map.entry("requestTime", "2026-08-25T12:34:56+09:00"), + Map.entry("vrtlPrafNo", "virtual-1"), + Map.entry("appCode", "app-1"), + Map.entry("projectCode", "project-1"), + Map.entry("userIp", "10.0.0.1"), + Map.entry("callerIp", "10.0.0.2"), + Map.entry("callerHost", "caller.example.test"), + Map.entry("channel", "MCP"), + Map.entry("agentId", "agent-1"), + Map.entry("mcpSessionId", "session-1")))); try { assertThat(component.call("status", Map.of(), SampleResponse.class).status()).isEqualTo("OK"); server.verify(); diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponentTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponentTest.java new file mode 100644 index 000000000..87b33b05e --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/mci/component/AxhubMciComponentTest.java @@ -0,0 +1,28 @@ +package io.shinhanlife.dat.lib.integration.mci.component; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dat.lib.config.GlowCommunicationProperties; +import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent; +import org.junit.jupiter.api.Test; + +class AxhubMciComponentTest { + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void doesNotReclassifyUnexpectedRuntimeFailuresAsBusinessFailures() { + GlowMciComponent glowMci = mock(GlowMciComponent.class); + IllegalStateException failure = new IllegalStateException("unexpected MCI adapter defect"); + when(glowMci.sync(any())).thenThrow(failure); + AxhubMciComponent component = new AxhubMciComponent(glowMci, new GlowCommunicationProperties()); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> + component.callTo("ONBSA0790", "ONBSA", new Object(), Object.class)); + + assertSame(failure, thrown); + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java index 8277a0fea..66d58f381 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java @@ -1,6 +1,11 @@ package io.shinhanlife.dat.lib.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; @@ -29,23 +34,72 @@ class McpToolExecutionServiceTest { void convertsArgumentsToDtoAndExecutesTheMatchedTool() { McpToolExecutionService service = serviceWith(new EchoTool()); ToolExecutionResult result = service.execute("sample.cmm.value.echo", - headers(Map.of( - "requestId", "request-1", - "guid", "guid-1", - "mcpSessionId", "session-1", - "employeeNo", "employee-1", - "virtualEmployeeNo", "virtual-1", - "headerRequestId", "request-1", - "traceId", "guid-1", - "encryptedEmployeeId", "employee-1")), + headers(Map.ofEntries( + Map.entry("guid", "guid-1"), + Map.entry("prafNo", "praf-1"), + Map.entry("requestId", "request-1"), + Map.entry("requestTime", "2026-08-25T12:34:56+09:00"), + Map.entry("vrtlPrafNo", "virtual-1"), + Map.entry("appCode", "app-1"), + Map.entry("projectCode", "project-1"), + Map.entry("userIp", "10.0.0.1"), + Map.entry("callerIp", "10.0.0.2"), + Map.entry("callerHost", "caller.example.test"), + Map.entry("channel", "MCP"), + Map.entry("agentId", "agent-1"), + Map.entry("mcpSessionId", "session-1"))), Map.of("value", "hello")); assertEquals(200, result.statusCode()); assertEquals("hello", ((Map) result.body()).get("value")); - assertEquals("request-1", result.headers().get("x-request-id")); - assertEquals("guid-1", result.headers().get("guid")); + assertEquals("request-1", result.headers().get("X-Request-Id")); + assertEquals("guid-1", result.headers().get("X-Guid")); assertEquals("session-1", result.headers().get("mcp-session-id")); } + @Test + void doesNotHideUnexpectedInputSchemaResolverFailures() { + ToolSchemaResolver resolver = mock(ToolSchemaResolver.class); + when(resolver.resolve(any(), any(), any())) + .thenThrow(new IllegalStateException("unexpected resolver defect")); + McpToolExecutionService service = serviceWith(new EchoTool(), resolver); + + assertThrows(IllegalStateException.class, () -> + service.execute("sample.cmm.value.echo", null, Map.of("value", "hello"))); + } + + @Test + void doesNotHideUnexpectedOutputSchemaResolverFailures() { + ToolSchemaResolver resolver = mock(ToolSchemaResolver.class); + when(resolver.resolveOutput(any(), any(), any())) + .thenThrow(new IllegalStateException("unexpected resolver defect")); + McpToolExecutionService service = serviceWith(new MapTool(), resolver); + + assertThrows(IllegalStateException.class, () -> + service.execute("sample.cmm.map.echo", null, Map.of("value", "hello"))); + } + + @Test + void propagatesUnexpectedRuntimeFailuresThrownByTheTool() { + UnexpectedToolFailure failure = new UnexpectedToolFailure(); + McpToolExecutionService service = serviceWith(new RuntimeFailureTool(failure)); + + UnexpectedToolFailure thrown = assertThrows(UnexpectedToolFailure.class, () -> + service.execute("sample.cmm.runtime.failure", null, Map.of())); + + assertSame(failure, thrown); + } + + @Test + void propagatesJvmErrorsThrownByTheTool() { + ToolJvmError error = new ToolJvmError(); + McpToolExecutionService service = serviceWith(new ErrorTool(error)); + + ToolJvmError thrown = assertThrows(ToolJvmError.class, () -> + service.execute("sample.cmm.error.failure", null, Map.of())); + + assertSame(error, thrown); + } + private McpRequestHeaders headers(Map values) { try { Class[] types = Arrays.stream(McpRequestHeaders.class.getRecordComponents()) @@ -61,6 +115,11 @@ class McpToolExecutionServiceTest { } private McpToolExecutionService serviceWith(Object toolBean) { + ObjectMapper objectMapper = new ObjectMapper(); + return serviceWith(toolBean, new ToolSchemaResolver(objectMapper)); + } + + private McpToolExecutionService serviceWith(Object toolBean, ToolSchemaResolver resolver) { ObjectMapper objectMapper = new ObjectMapper(); StaticApplicationContext context = new StaticApplicationContext(); context.getBeanFactory().registerSingleton("toolBean", toolBean); @@ -69,7 +128,7 @@ class McpToolExecutionServiceTest { registry.initialize(); return new McpToolExecutionService(registry, objectMapper, new ToolArgumentSchemaValidator(objectMapper, new DefaultJsonSchemaValidator()), - new ToolSchemaResolver(objectMapper)); + resolver); } static class EchoTool { @@ -80,6 +139,45 @@ class McpToolExecutionServiceTest { } } + static class MapTool { + @McpTool(name = "sample.cmm.map.echo", description = "Echoes a map") + public Map execute(Map request) { + return request; + } + } + + static class RuntimeFailureTool { + private final UnexpectedToolFailure failure; + + RuntimeFailureTool(UnexpectedToolFailure failure) { + this.failure = failure; + } + + @McpTool(name = "sample.cmm.runtime.failure") + public Map execute(Map request) { + throw failure; + } + } + + static class ErrorTool { + private final ToolJvmError error; + + ErrorTool(ToolJvmError error) { + this.error = error; + } + + @McpTool(name = "sample.cmm.error.failure") + public Map execute(Map request) { + throw error; + } + } + + static class UnexpectedToolFailure extends RuntimeException { + } + + static class ToolJvmError extends Error { + } + static class EchoRequest { @McpToolParam(description = "Value", required = true) private String value; diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionServiceTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionServiceTest.java new file mode 100644 index 000000000..17bc4de34 --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/session/mci/AxhubSessionServiceTest.java @@ -0,0 +1,134 @@ +package io.shinhanlife.dat.lib.session.mci; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent; +import io.shinhanlife.dat.lib.session.dto.SessionDto; +import io.shinhanlife.glow.BizException; +import io.shinhanlife.glow.communication.annotation.GlowTrgmField; +import io.shinhanlife.glow.communication.dto.Transfer; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AxhubSessionServiceTest { + + private static final String PACKAGE = "io.shinhanlife.dat.lib.session.mci."; + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void fetchesSessionThroughOnbsz0460ListContract() throws Exception { + Class requestClass = requiredClass("ONBSZ0460_I"); + Class responseClass = requiredClass("ONBSZ0460_O"); + Class outputClass = requiredClass("ONBSZ0460_O$CmmnPrafIfinOutDto"); + Class clientClass = requiredClass("MciOnbszClient"); + + Object output = outputClass.getDeclaredConstructor().newInstance(); + set(outputClass, output, "PrafNo", "1234567"); + set(outputClass, output, "OgnzAsrtCd", "ORG001"); + set(outputClass, output, "BrafNo", "BR001"); + set(outputClass, output, "PsmrAsrtCd", "PSMR01"); + set(outputClass, output, "SbsnRulpAsrtCd", "RULE01"); + set(outputClass, output, "BsduCd", "BSD"); + set(outputClass, output, "BsquCd", "BQ"); + set(outputClass, output, "OgnzLeveCd", "LEVEL1"); + + Object response = responseClass.getDeclaredConstructor().newInstance(); + responseClass.getMethod("setCmmnPrafIfinOutDto", List.class).invoke(response, List.of(output)); + Transfer transfer = mock(Transfer.class); + when(transfer.getBody()).thenReturn(response); + AxhubMciComponent mci = mock(AxhubMciComponent.class); + when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass))) + .thenReturn(transfer); + + Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci); + AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client); + + SessionDto session = service.fetchUserSession("1234567"); + + assertEquals("1234567", session.getPrafNo()); + assertEquals("ORG001", session.getOgnzNo()); + assertEquals("BR001", session.getBrafNo()); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Object.class); + verify(mci).callTo(eq("ONBSZ0460"), eq("ONBSZ"), requestCaptor.capture(), eq((Class) responseClass)); + Object request = requestCaptor.getValue(); + assertEquals(requestClass, request.getClass()); + List inputs = (List) requestClass.getMethod("getCmmnPrafIfinInDto").invoke(request); + assertEquals(1, inputs.size()); + assertEquals("1234567", inputs.get(0).getClass().getMethod("getPrafNo").invoke(inputs.get(0))); + } + + @Test + void exposesSeventyEightOutputMappingSlots() { + Class outputClass = requiredClass("ONBSZ0460_O$CmmnPrafIfinOutDto"); + Field[] fields = outputClass.getDeclaredFields(); + + assertEquals(78, fields.length); + List actualOrders = Arrays.stream(fields) + .map(field -> field.getAnnotation(GlowTrgmField.class)) + .map(GlowTrgmField::order) + .sorted() + .toList(); + assertEquals(IntStream.rangeClosed(1, 78).boxed().toList(), actualOrders); + } + + @Test + void removesOldOnbsa0790Types() { + assertThrows(ClassNotFoundException.class, () -> Class.forName(PACKAGE + "ONBSA0790_I")); + assertThrows(ClassNotFoundException.class, () -> Class.forName(PACKAGE + "ONBSA0790_O")); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void returnsNullWhenMciReportsABusinessCommunicationFailure() throws Exception { + Class responseClass = requiredClass("ONBSZ0460_O"); + Class clientClass = requiredClass("MciOnbszClient"); + AxhubMciComponent mci = mock(AxhubMciComponent.class); + when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass))) + .thenThrow(bizException()); + Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci); + AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client); + + assertNull(service.fetchUserSession("1234567")); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void doesNotHideUnexpectedRuntimeFailures() throws Exception { + Class responseClass = requiredClass("ONBSZ0460_O"); + Class clientClass = requiredClass("MciOnbszClient"); + AxhubMciComponent mci = mock(AxhubMciComponent.class); + when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass))) + .thenThrow(new IllegalStateException("unexpected defect")); + Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci); + AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client); + + assertThrows(IllegalStateException.class, () -> service.fetchUserSession("1234567")); + } + + private Class requiredClass(String simpleName) { + return assertDoesNotThrow(() -> Class.forName(PACKAGE + simpleName)); + } + + private void set(Class type, Object target, String property, String value) throws Exception { + Method setter = type.getMethod("set" + property, String.class); + setter.invoke(target, value); + } + + private BizException bizException() { + return new BizException("CST00477", new String[]{"MCI communication failure"}, + new RuntimeException("connection closed")); + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java index a1cff10a5..5bdb32b18 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java @@ -1,9 +1,5 @@ package io.shinhanlife.dat.lib.util; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import io.shinhanlife.dat.lib.metadata.ToolDefinition; -import io.shinhanlife.dat.lib.metadata.ToolDefinitionValidator; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -42,13 +38,13 @@ class ToolScaffolderTest { Path sourceRoot = root.resolve("dat-was-customer/src/main/java/io/shinhanlife/dat/mcc"); String useCase = Files.readString(sourceRoot.resolve("biz/cmm/usecase/CustomerUseCase.java")); String implementation = Files.readString(sourceRoot.resolve("biz/cmm/usecase/impl/CustomerUseCaseImpl.java")); - String guidanceClient = Files.readString(sourceRoot.resolve("infra/itrf/mci/nild/CustomerGuidanceClient.java")); + String guidanceClient = Files.readString(sourceRoot.resolve("infra/itrf/mci/nil/d/MciNildClient.java")); assertTrue(useCase.contains("CustomerGuidanceResponse searchGuidance(CustomerGuidanceRequest req)"), useCase); assertTrue(useCase.contains("CustomerContractResponse searchContract(CustomerContractRequest req)"), useCase); - assertTrue(implementation.contains("private final CustomerGuidanceClient customerGuidanceClient;"), implementation); - assertTrue(implementation.contains("customerGuidanceClient.callCustomerGuidance(request)"), implementation); - assertTrue(guidanceClient.contains("CustomerGuidance_O callCustomerGuidance(CustomerGuidance_I request)"), guidanceClient); + assertTrue(implementation.contains("private final MciNildClient mciNildClient;"), implementation); + assertTrue(implementation.contains("mciNildClient.callTo(\"CTMNILO00007\", null, request, CTMNILO00007_O.class)"), implementation); + assertTrue(guidanceClient.contains("class MciNildClient"), guidanceClient); } @Test @@ -85,7 +81,6 @@ class ToolScaffolderTest { Path dtoRoot = root.resolve("dat-was-claim/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/dto"); String request = Files.readString(dtoRoot.resolve("ClaimSearchRequest.java")); String response = Files.readString(dtoRoot.resolve("ClaimSearchResponse.java")); - String definition = Files.readString(root.resolve("dat-was-claim/src/main/resources/tool-definitions/cmm/cmm_claim_search.yml")); String mock = Files.readString(root.resolve("dat-was-claim/src/main/resources/mock-responses/cmm_claim_search.json")); assertTrue(request.contains("private ClaimStatus claimStatus;"), request); @@ -94,8 +89,6 @@ class ToolScaffolderTest { assertTrue(response.contains("private List guidanceItems;"), response); assertTrue(response.contains("public static class GuidanceItemsItem"), response); assertFalse(Files.exists(dtoRoot.resolve("ClaimSearchResponseGuidanceItemsItem.java"))); - assertTrue(definition.contains("enum: [OPEN, CLOSED]"), definition); - assertTrue(definition.contains("type: array"), definition); assertTrue(mock.contains("\"guidanceItems\" : [{"), mock); } @@ -140,7 +133,7 @@ class ToolScaffolderTest { } @Test - void generatesV17ToolDefinitionTogetherWithToolSources() throws Exception { + void generatesGrowToolHintMetadataTogetherWithToolSources() throws Exception { String moduleName = root.resolve("dat-was-v17-definition").toString(); ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", @@ -149,16 +142,13 @@ class ToolScaffolderTest { List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("09860000"), "", true)), List.of(), "employee"); - Path definition = root.resolve("dat-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); - String yaml = Files.readString(definition); + Path useCasePath = root.resolve("dat-was-v17-definition/src/main/java/io/shinhanlife/dat/mcc/biz/smp/usecase/EmployeeSearchUseCase.java"); + String useCase = Files.readString(useCasePath); - assertTrue(yaml.contains("name: smp_employee_search"), yaml); - assertTrue(yaml.contains("when_to_use:"), yaml); - assertTrue(yaml.contains("example_queries:"), yaml); - assertTrue(yaml.contains("additionalProperties: false"), yaml); - assertTrue(yaml.contains("owner_org: \"MCP_TOOL\""), yaml); - ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(yaml, ToolDefinition.class); - ToolDefinitionValidator.validate(parsed, definition.toString()); + assertTrue(useCase.contains("name = \"smp_employee_search\""), useCase); + assertTrue(useCase.contains("whenToUse = \"사용자가 이 업무 기능의 실행 또는 조회를 요청할 때 사용합니다.\""), useCase); + assertTrue(useCase.contains("exampleQueries = {\"직원 조회 정보를 보여줘\""), useCase); + assertTrue(useCase.contains("ownerOrg = \"MCP_TOOL\""), useCase); } @Test @@ -178,12 +168,12 @@ class ToolScaffolderTest { List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("10001"), "", true)), List.of(), "employee", options); - Path definition = root.resolve("dat-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); - ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(Files.readString(definition), ToolDefinition.class); - assertEquals("HR_TEAM", parsed.ownerOrg()); - assertEquals("10001 직원을 찾아줘", parsed.exampleQueries().get(2)); - assertEquals(2, parsed.tags().size()); - ToolDefinitionValidator.validate(parsed, definition.toString()); + Path useCasePath = root.resolve("dat-was-v17-options/src/main/java/io/shinhanlife/dat/mcc/biz/smp/usecase/EmployeeSearchUseCase.java"); + String useCase = Files.readString(useCasePath); + assertTrue(useCase.contains("functionDescription = \"사번으로 직원을 조회한다.\""), useCase); + assertTrue(useCase.contains("exampleQueries = {\"사번 10001을 조회해줘\", \"직원 10001 소속을 알려줘\", \"10001 직원을 찾아줘\"}"), useCase); + assertTrue(useCase.contains("tags = {\"employee\", \"search\"}"), useCase); + assertTrue(useCase.contains("ownerOrg = \"HR_TEAM\""), useCase); } @TempDir @@ -201,7 +191,9 @@ class ToolScaffolderTest { String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java")); assertTrue(useCase.contains("name = \"cmm_claim_search\"")); - assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\")")); + assertTrue(useCase.contains("requiresApproval = false"), useCase); + assertTrue(useCase.contains("categoryKey = \"cmm\""), useCase); + assertTrue(useCase.contains("mappingId = \"CLM0001\""), useCase); assertTrue(response.contains("private String resultCode;")); assertTrue(response.contains("private String resultMessage;")); } @@ -231,9 +223,10 @@ class ToolScaffolderTest { Path schemas = root.resolve("dat-was-sample/src/main/resources/tool-schemas/cmm"); assertTrue(Files.exists(schemas.resolve("claim-search-resource-input-schema.json"))); assertTrue(Files.exists(schemas.resolve("claim-search-resource-output-schema.json"))); String useCase = Files.readString(root.resolve("dat-was-sample/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/ClaimSearchUseCase.java")); - assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\",")); - assertTrue(useCase.contains("inputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json\"")); - assertTrue(useCase.contains("outputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json\"")); + assertTrue(useCase.contains("categoryKey = \"cmm\""), useCase); + assertTrue(useCase.contains("mappingId = \"CLM0001\""), useCase); + assertTrue(useCase.contains("inputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json\""), useCase); + assertTrue(useCase.contains("outputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json\""), useCase); } @Test @@ -272,12 +265,14 @@ class ToolScaffolderTest { Path sourceRoot = root.resolve("dat-was-pay/src/main/java/io/shinhanlife/dat/mcc"); String request = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrRequest.java")); String response = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrResponse.java")); - String mciRequest = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_I.java")); - String mciResponse = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_O.java")); + String mciRequest = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfa/g/io/SHEARCH_01_I.java")); + String mciResponse = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfa/g/io/SHEARCH_01_O.java")); String converter = Files.readString(sourceRoot.resolve("biz/pay/converter/SearchHrConverter.java")); String useCase = Files.readString(sourceRoot.resolve("biz/pay/usecase/SearchHrUseCase.java")); String implementation = Files.readString(sourceRoot.resolve("biz/pay/usecase/impl/SearchHrUseCaseImpl.java")); - assertTrue(mciRequest.contains("package io.shinhanlife.dat.mcc.infra.itrf.mci.dfag.io;"), mciRequest); assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"pay\", mappingId = \"SHEARCH_01\")")); - assertTrue(implementation.contains("import io.shinhanlife.dat.mcc.infra.itrf.mci.dfag.io.SHEARCH_01_O;")); + assertTrue(mciRequest.contains("package io.shinhanlife.dat.mcc.infra.itrf.mci.dfa.g.io;"), mciRequest); + assertTrue(useCase.contains("categoryKey = \"pay\""), useCase); + assertTrue(useCase.contains("mappingId = \"SHEARCH_01\""), useCase); + assertTrue(implementation.contains("import io.shinhanlife.dat.mcc.infra.itrf.mci.dfa.g.io.SHEARCH_01_O;")); assertTrue(request.contains("private String employeeId;")); assertTrue(request.contains("private Integer page;")); @@ -481,7 +476,7 @@ class ToolScaffolderTest { String impl = Files.readString(sourceRoot.resolve("biz/cmm/usecase/impl/CustomerUseCaseImpl.java")); assertTrue(useCase.contains("getProfile(CustomerProfileRequest req)"), useCase); assertTrue(useCase.contains("getNotice(CustomerNoticeRequest req)"), useCase); - assertTrue(impl.contains("private final CustomerProfileClient customerProfileClient;"), impl); + assertTrue(impl.contains("private final MciCstmClient mciCstmClient;"), impl); assertTrue(impl.contains("private final CustomerNoticeClient customerNoticeClient;"), impl); } } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolSchemaResolverTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolSchemaResolverTest.java index acc245547..c44b43eb6 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolSchemaResolverTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolSchemaResolverTest.java @@ -1,11 +1,16 @@ package io.shinhanlife.dat.lib.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dat.lib.annotation.McpOutputSchema; +import io.shinhanlife.dat.lib.annotation.GrowToolHint; +import io.shinhanlife.dat.lib.util.ToolSchemaResolver.ToolSchemaResourceException; import io.swagger.v3.oas.annotations.media.Schema; +import java.io.IOException; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -46,6 +51,29 @@ class ToolSchemaResolverTest { assertTrue(schema.isEmpty()); } + @Test + void identifiesMalformedSchemaResourcesAsSchemaResourceFailures() throws Exception { + Method method = ResourceSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + + ToolSchemaResourceException exception = assertThrows(ToolSchemaResourceException.class, () -> + resolver.resolve(method.getAnnotation(McpTool.class), + method.getAnnotation(GrowToolHint.class), + AutomaticRequest.class)); + + assertInstanceOf(IOException.class, exception.getCause()); + } + + @Test + void identifiesMissingSchemaResourcesAsSchemaResourceFailures() throws Exception { + Method method = MissingResourceSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + + ToolSchemaResourceException exception = assertThrows(ToolSchemaResourceException.class, () -> + resolver.resolve(method.getAnnotation(McpTool.class), + method.getAnnotation(GrowToolHint.class), AutomaticRequest.class)); + + assertTrue(exception.getMessage().contains("missing-tool-schema.json")); + } + @SuppressWarnings("unchecked") private Map properties(Map schema) { return (Map) schema.get("properties"); @@ -69,6 +97,21 @@ class ToolSchemaResolverTest { } } + static class ResourceSchemaTool { + @McpTool(name = "oth_test_resource_schema") + @GrowToolHint( + inputSchemaResource = "classpath:schemas/malformed-tool-schema.json") + void search(AutomaticRequest request) { + } + } + + static class MissingResourceSchemaTool { + @McpTool(name = "oth_test_missing_resource_schema") + @GrowToolHint(inputSchemaResource = "classpath:schemas/missing-tool-schema.json") + void search(AutomaticRequest request) { + } + } + @McpOutputSchema static class SimpleResponse { @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"SUCCESS", "FAILURE"}) @@ -80,4 +123,4 @@ class ToolSchemaResolverTest { static class AutomaticRequest { private String differentField; } -} \ No newline at end of file +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java index fd8264103..6e0512f38 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java @@ -16,21 +16,37 @@ import org.springframework.mock.web.MockHttpServletResponse; class McpRequestHeaderFilterTest { @Test - void capturesDapmsHeadersOnlyForTheCurrentRequest() throws Exception { + void capturesCanonicalToolHeadersOnlyForTheCurrentRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("x-request-id", "request-001"); - request.addHeader("guid", "guid-001"); + request.addHeader("X-Guid", "guid-001"); + request.addHeader("X-Praf-No", "praf-001"); + request.addHeader("X-Request-Id", "request-001"); + request.addHeader("X-Request-Time", "2026-08-25T12:34:56+09:00"); + request.addHeader("X-Vrtl-Praf-No", "virtual-001"); + request.addHeader("X-App-Code", "app-001"); + request.addHeader("X-Project-Code", "project-001"); + request.addHeader("X-User-Ip", "10.0.0.1"); + request.addHeader("X-Caller-Ip", "10.0.0.2"); + request.addHeader("X-Caller-Host", "caller.example.test"); + request.addHeader("X-Channel", "MCP"); + request.addHeader("X-Agent-Id", "agent-001"); request.addHeader("mcp-session-id", "session-001"); - request.addHeader("employee-no", "ENC(employee)"); - request.addHeader("virtual-employee-no", "ENC(virtual)"); new McpRequestHeaderFilter().doFilter(request, new MockHttpServletResponse(), (req, res) -> assertThat(asMap(McpRequestHeaderContext.current())).containsExactly( - Map.entry("requestId", "request-001"), Map.entry("guid", "guid-001"), - Map.entry("mcpSessionId", "session-001"), - Map.entry("employeeNo", "ENC(employee)"), - Map.entry("virtualEmployeeNo", "ENC(virtual)"))); + Map.entry("prafNo", "praf-001"), + Map.entry("requestId", "request-001"), + Map.entry("requestTime", "2026-08-25T12:34:56+09:00"), + Map.entry("vrtlPrafNo", "virtual-001"), + Map.entry("appCode", "app-001"), + Map.entry("projectCode", "project-001"), + Map.entry("userIp", "10.0.0.1"), + Map.entry("callerIp", "10.0.0.2"), + Map.entry("callerHost", "caller.example.test"), + Map.entry("channel", "MCP"), + Map.entry("agentId", "agent-001"), + Map.entry("mcpSessionId", "session-001"))); assertNull(McpRequestHeaderContext.current()); } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java index 60ed42bef..8a1d75610 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestHeader; class BusinessToolControllerHeaderContractTest { @Test - void receivesTheHeadersForwardedByDapms() { + void receivesTheCanonicalToolHeadersAsOptionalValues() { Method method = Arrays.stream(BusinessToolController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals("executeDynamicTool")) .findFirst() @@ -25,6 +25,18 @@ class BusinessToolControllerHeaderContractTest { .toList(); assertThat(headerNames).containsExactly( - "x-request-id", "guid", "mcp-session-id", "employee-no", "virtual-employee-no"); + "X-Guid", + "X-Praf-No", + "X-Request-Id", + "X-Request-Time", + "X-Vrtl-Praf-No", + "X-App-Code", + "X-Project-Code", + "X-User-Ip", + "X-Caller-Ip", + "X-Caller-Host", + "X-Channel", + "X-Agent-Id", + "mcp-session-id"); } } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java index 315b00963..0d235f9ce 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java @@ -1,6 +1,7 @@ package io.shinhanlife.dat.mcc.presentation; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.InputStream; @@ -18,7 +19,21 @@ class ToolTestConsoleResourceTest { assertTrue(html.contains("/tool-manifest")); assertTrue(html.contains("Run saved cases")); assertTrue(html.contains("localStorage")); - assertTrue(html.contains("request-id")); + assertTrue(html.contains("X-Guid")); + assertTrue(html.contains("X-Praf-No")); + assertTrue(html.contains("X-Request-Id")); + assertTrue(html.contains("X-Request-Time")); + assertTrue(html.contains("X-Vrtl-Praf-No")); + assertTrue(html.contains("X-App-Code")); + assertTrue(html.contains("X-Project-Code")); + assertTrue(html.contains("X-User-Ip")); + assertTrue(html.contains("X-Caller-Ip")); + assertTrue(html.contains("X-Caller-Host")); + assertTrue(html.contains("X-Channel")); + assertTrue(html.contains("X-Agent-Id")); + assertFalse(html.contains("'guid':")); + assertFalse(html.contains("'employee-no':")); + assertFalse(html.contains("'virtual-employee-no':")); } } -} \ No newline at end of file +} diff --git a/dat-was-lib/src/test/resources/mock-responses/invalid_mock_response.json b/dat-was-lib/src/test/resources/mock-responses/invalid_mock_response.json new file mode 100644 index 000000000..828e21da4 --- /dev/null +++ b/dat-was-lib/src/test/resources/mock-responses/invalid_mock_response.json @@ -0,0 +1 @@ +{ invalid json diff --git a/dat-was-lib/src/test/resources/schemas/malformed-tool-schema.json b/dat-was-lib/src/test/resources/schemas/malformed-tool-schema.json new file mode 100644 index 000000000..72dc08b36 --- /dev/null +++ b/dat-was-lib/src/test/resources/schemas/malformed-tool-schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": { +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/converter/PersonalCustomerDetailInquiryConverter.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/converter/PersonalCustomerDetailInquiryConverter.java new file mode 100644 index 000000000..699e97671 --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/converter/PersonalCustomerDetailInquiryConverter.java @@ -0,0 +1,19 @@ +package io.shinhanlife.dat.mcc.biz.pro.converter; + +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest; +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_I; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_O; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface PersonalCustomerDetailInquiryConverter { + // Field names differ? Add mappings like this before the method. + @Mapping(source = "customerId", target = "customerId") + DATNCSO00001_I toLegacyRequest(PersonalCustomerDetailInquiryRequest request); + + + PersonalCustomerDetailInquiryResponse toResponse( DATNCSO00001_O mciRes); +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryRequest.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryRequest.java new file mode 100644 index 000000000..5b4be65e8 --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryRequest.java @@ -0,0 +1,26 @@ +package io.shinhanlife.dat.mcc.biz.pro.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import jakarta.validation.constraints.Pattern; + + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PersonalCustomerDetailInquiryRequest { + @Schema(description = "조회할 고객의 고유 ID (형식: ^[A-Za-z0-9-]{1,20}$) (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED) + @Pattern(regexp = "^[A-Za-z0-9-]{1,20}$") + private String customerId; + + @Schema(description = "고객 이름 (선택적 확인용) (형식: ^[가-힣a-zA-Z\s]{2,20}$) (예시: 김철수, Lee Chul-soo)", example = "김철수") + @Pattern(regexp = "^[가-힣a-zA-Z\s]{2,20}$") + private String name; + + @Schema(description = "고객 전화번호 (선택적 확인용) (형식: $) (예시: 010-1234-5678, +821012345678)", example = "010-1234-5678") + private String phoneNumber; + + @Schema(description = "고객 이메일 주소 (선택적 확인용) (형식: $) (예시: customer@example.com, user@test.co.kr)", example = "customer@example.com") + private String email; + +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryResponse.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryResponse.java new file mode 100644 index 000000000..fd93782b8 --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/dto/PersonalCustomerDetailInquiryResponse.java @@ -0,0 +1,35 @@ +package io.shinhanlife.dat.mcc.biz.pro.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PersonalCustomerDetailInquiryResponse { + private String resultCode; + + private String resultMessage; + @Schema(description = "조회된 고객의 고유 ID (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Schema(description = "고객의 전체 이름 (예시: 김철수, Lee Chul-soo)", example = "김철수", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "고객의 전화번호 (예시: 010-1234-5678)", example = "010-1234-5678", requiredMode = Schema.RequiredMode.REQUIRED) + private String phoneNumber; + + @Schema(description = "고객의 이메일 주소 (예시: customer@example.com)", example = "customer@example.com", requiredMode = Schema.RequiredMode.REQUIRED) + private String email; + + @Schema(description = "고객의 주소 정보 (예시: 서울시 강남구 테헤란로 123)", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "고객의 생년월일 (예시: 1990-01-15)", example = "1990-01-15") + private String birthDate; + + @Schema(description = "고객의 멤버십 등급 (예시: GOLD, SILVER, BRONZE)", example = "GOLD") + private String membershipLevel; + +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCase.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCase.java new file mode 100644 index 000000000..4070d48fc --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCase.java @@ -0,0 +1,41 @@ +package io.shinhanlife.dat.mcc.biz.pro.usecase; + +import org.springaicommunity.mcp.annotation.McpTool; +import io.shinhanlife.dat.lib.annotation.GrowToolHint; +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest; +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse; + +/** + * @package io.shinhanlife.dat.mcc.biz.pro.usecase + * @className PersonalCustomerDetailInquiryUseCase + * @description AX HUB 시스템 처리 클래스 + * @author jade + * @create 2026.08.25 + *
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
+ * ---------- -------- ---------------------------
+ * 2026.08.25  jade    최초생성
+ *
+ * 
+ */ +public interface PersonalCustomerDetailInquiryUseCase { + + @McpTool(name = "pro_personal_inquiry", title = "개인고객 상세 정보 조회", description = "개인고객의 상세 정보를 조회하는 도구로, 고객 ID 또는 기본 정보를 통해 이름, 연락처, 주소, 생년월일, 멤버십 등급 등 개인 고객 정보를 안전하게 조회할 수 있습니다.") + @GrowToolHint( + requiresApproval = false, + categoryKey = "pro", + mappingId = " DATNCSO00001", + functionDescription = "고객 ID 또는 기본 정보를 기반으로 개인 고객의 상세 정보를 조회하고 반환하는 핵심 비즈니스 함수입니다.", + whenToUse = "개인 고객의 상세 정보가 필요한 경우, 예를 들어 고객 ID, 이름, 전화번호, 이메일 등을 입력하여 고객 정보를 조회하고자 할 때 이 도구를 선택해야 합니다.", + whenNotToUse = "개인 고객이 아닌 법인 고객 정보 조회, 익명 또는 비개인 정보 요청, 또는 개인 정보 보호 정책상 정보 조회가 제한된 경우 이 도구를 선택하지 않아야 합니다.", + ioLimits = "입력 필드는 고객 ID(필수) 및 선택적 이름, 전화번호, 이메일을 허용하며, 최대 10개의 필드를 포함할 수 있습니다. 출력 필드는 결과 코드, 고객 ID, 이름, 전화번호, 이메일, 주소, 생년월일, 멤버십 등급 등을 포함하며, 최대 10개의 필드를 반환할 수 있습니다.", + displayDescription = "개인 고객의 상세 정보를 조회하는 포털용 간략한 설명으로, 이름, 연락처, 주소, 생년월일, 멤버십 등급 등을 확인할 수 있습니다.", + exampleQueries = {"고객 ID 12345로 개인 고객 정보를 조회해줘", "김철수 고객의 상세 정보를 알려줘", "010-1234-5678로 등록된 개인 고객 정보를 조회해줘", "lee@example.com 이메일로 가입된 개인 고객 정보를 조회해줘"}, + destructive = false, + idempotent = true, + tags = {"고객관리", "조회"}, + ownerOrg = "MCP_TOOL" + ) + PersonalCustomerDetailInquiryResponse execute(PersonalCustomerDetailInquiryRequest req); +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/impl/PersonalCustomerDetailInquiryUseCaseImpl.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/impl/PersonalCustomerDetailInquiryUseCaseImpl.java new file mode 100644 index 000000000..a7d06058c --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/biz/pro/usecase/impl/PersonalCustomerDetailInquiryUseCaseImpl.java @@ -0,0 +1,69 @@ +package io.shinhanlife.dat.mcc.biz.pro.usecase.impl; + +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest; +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse; +import io.shinhanlife.dat.mcc.biz.pro.usecase.PersonalCustomerDetailInquiryUseCase; +import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent; +import io.shinhanlife.glow.communication.dto.Transfer; +import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import io.shinhanlife.dat.mcc.biz.pro.converter.PersonalCustomerDetailInquiryConverter; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_I; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_O; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.MciNcscClient; + + +/** + * @package io.shinhanlife.dat.mcc.biz.pro.usecase.impl + * @className PersonalCustomerDetailInquiryUseCaseImpl + * @description AX HUB 시스템 처리 클래스 + * @author jade + * @create 2026.08.25 + *
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
+ * ---------- -------- ---------------------------
+ * 2026.08.25  jade    최초생성
+ *
+ * 
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PersonalCustomerDetailInquiryUseCaseImpl implements PersonalCustomerDetailInquiryUseCase { + + private final MciNcscClient mci; + private final PersonalCustomerDetailInquiryConverter converter; + + @Override + public PersonalCustomerDetailInquiryResponse execute(PersonalCustomerDetailInquiryRequest req) { + log.info("[MCI Tool] {} 요청 수신.", "pro_personal_inquiry"); + try { + // MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO) + DATNCSO00001_I mciReq = converter.toLegacyRequest(req); + + Transfer< DATNCSO00001_O> resTransfer = mci.callTo( + " DATNCSO00001", + null, + mciReq, + DATNCSO00001_O.class + ); + PersonalCustomerDetailInquiryResponse response = new PersonalCustomerDetailInquiryResponse(); + if (resTransfer.getBody() != null) { + response = converter.toResponse(resTransfer.getBody()); + } + response.setResultCode("SUCCESS"); + response.setResultMessage(resTransfer.getBody() != null + ? "MCI call completed." + : "MCI call completed without a response body."); + return response; + } catch (Exception e) { + log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e); + PersonalCustomerDetailInquiryResponse response = new PersonalCustomerDetailInquiryResponse(); + response.setResultCode("ERROR"); + response.setResultMessage(e.getMessage() != null ? e.getMessage() : "Unknown error"); + return response; + } + } +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/MciNcscClient.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/MciNcscClient.java new file mode 100644 index 000000000..b1a142132 --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/MciNcscClient.java @@ -0,0 +1,30 @@ +package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c; + +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent; +import io.shinhanlife.glow.communication.dto.Transfer; + +/** + * @package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c + * @className MciNcscClient + * @description AX HUB 시스템 처리 클래스 + * @author jade + * @create 2026.08.25 + *
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
+ * ---------- -------- ---------------------------
+ * 2026.08.25  jade    최초생성
+ *
+ * 
+ */ +@Component +@RequiredArgsConstructor +public class MciNcscClient { + private final AxhubMciComponent mci; + + public Transfer callTo(String interfaceId, String dummy, Object mciReq, Class resType) throws Exception { + return mci.callTo(interfaceId, dummy, mciReq, resType); + } +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_I.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_I.java new file mode 100644 index 000000000..c92cd261e --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_I.java @@ -0,0 +1,24 @@ +package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import jakarta.validation.constraints.Pattern; + + +@Data +public class DATNCSO00001_I { + @Schema(description = "조회할 고객의 고유 ID (형식: ^[A-Za-z0-9-]{1,20}$) (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED) + @Pattern(regexp = "^[A-Za-z0-9-]{1,20}$") + private String customerId; + + @Schema(description = "고객 이름 (선택적 확인용) (형식: ^[가-힣a-zA-Z\s]{2,20}$) (예시: 김철수, Lee Chul-soo)", example = "김철수") + @Pattern(regexp = "^[가-힣a-zA-Z\s]{2,20}$") + private String name; + + @Schema(description = "고객 전화번호 (선택적 확인용) (형식: {4}$) (예시: 010-1234-5678, +821012345678)", example = "010-1234-5678") + private String phoneNumber; + + @Schema(description = "고객 이메일 주소 (선택적 확인용) (형식: $) (예시: customer@example.com, user@test.co.kr)", example = "customer@example.com") + private String email; + +} diff --git a/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_O.java b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_O.java new file mode 100644 index 000000000..114749660 --- /dev/null +++ b/dat-was-pro/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncs/c/io/DATNCSO00001_O.java @@ -0,0 +1,33 @@ +package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Data +public class DATNCSO00001_O { + @Schema(description = "조회 결과 상태 코드 (예시: SUCCESS, ERROR_001)", example = "SUCCESS", requiredMode = Schema.RequiredMode.REQUIRED) + private String resultCode; + + @Schema(description = "조회된 고객의 고유 ID (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Schema(description = "고객의 전체 이름 (예시: 김철수, Lee Chul-soo)", example = "김철수", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "고객의 전화번호 (예시: 010-1234-5678)", example = "010-1234-5678", requiredMode = Schema.RequiredMode.REQUIRED) + private String phoneNumber; + + @Schema(description = "고객의 이메일 주소 (예시: customer@example.com)", example = "customer@example.com", requiredMode = Schema.RequiredMode.REQUIRED) + private String email; + + @Schema(description = "고객의 주소 정보 (예시: 서울시 강남구 테헤란로 123)", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "고객의 생년월일 (예시: 1990-01-15)", example = "1990-01-15") + private String birthDate; + + @Schema(description = "고객의 멤버십 등급 (예시: GOLD, SILVER, BRONZE)", example = "GOLD") + private String membershipLevel; + +} diff --git a/dat-was-pro/src/main/resources/mock-responses/pro_personal_inquiry.json b/dat-was-pro/src/main/resources/mock-responses/pro_personal_inquiry.json new file mode 100644 index 000000000..817d4d472 --- /dev/null +++ b/dat-was-pro/src/main/resources/mock-responses/pro_personal_inquiry.json @@ -0,0 +1,10 @@ +{ + "resultCode" : "SUCCESS", + "customerId" : "12345", + "name" : "김철수", + "phoneNumber" : "010-1234-5678", + "email" : "customer@example.com", + "address" : "서울시 강남구 테헤란로 123", + "birthDate" : "1990-01-15", + "membershipLevel" : "GOLD" +} diff --git a/dat-was-pro/src/test/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCaseTest.java b/dat-was-pro/src/test/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCaseTest.java new file mode 100644 index 000000000..224aec626 --- /dev/null +++ b/dat-was-pro/src/test/java/io/shinhanlife/dat/mcc/biz/pro/usecase/PersonalCustomerDetailInquiryUseCaseTest.java @@ -0,0 +1,16 @@ +package io.shinhanlife.dat.mcc.biz.pro.usecase; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest; +import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse; +import org.junit.jupiter.api.Test; + +class PersonalCustomerDetailInquiryUseCaseTest { + + @Test + void createsToolRequestAndResponseDtos() { + assertNotNull(new PersonalCustomerDetailInquiryRequest()); + assertNotNull(new PersonalCustomerDetailInquiryResponse()); + } +} diff --git a/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImpl.java b/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImpl.java index 22532d330..801b01255 100644 --- a/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImpl.java +++ b/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImpl.java @@ -4,6 +4,7 @@ import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchRequest; import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchResponse; import io.shinhanlife.dat.mcc.biz.cmm.usecase.ClaimSearchUseCase; import io.shinhanlife.glow.communication.dto.Transfer; +import io.shinhanlife.glow.BizException; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -57,7 +58,7 @@ public class ClaimSearchUseCaseImpl implements ClaimSearchUseCase { ? "MCI call completed." : "MCI call completed without a response body."); return response; - } catch (Exception e) { + } catch (BizException e) { log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e); ClaimSearchResponse response = new ClaimSearchResponse(); response.setResultCode("ERROR"); diff --git a/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncla/MciNclaClient.java b/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncla/MciNclaClient.java index ca1beaf49..6bac3b0c6 100644 --- a/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncla/MciNclaClient.java +++ b/dat-was-sal/src/main/java/io/shinhanlife/dat/mcc/infra/itrf/mci/ncla/MciNclaClient.java @@ -24,7 +24,7 @@ import io.shinhanlife.glow.communication.dto.Transfer; public class MciNclaClient { private final AxhubMciComponent mci; - public Transfer callTo(String interfaceId, String dummy, Object mciReq, Class resType) throws Exception { + public Transfer callTo(String interfaceId, String dummy, Object mciReq, Class resType) { return mci.callTo(interfaceId, dummy, mciReq, resType); } } diff --git a/dat-was-sal/src/test/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImplTest.java b/dat-was-sal/src/test/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImplTest.java new file mode 100644 index 000000000..cdccd7d6b --- /dev/null +++ b/dat-was-sal/src/test/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/impl/ClaimSearchUseCaseImplTest.java @@ -0,0 +1,47 @@ +package io.shinhanlife.dat.mcc.biz.cmm.usecase.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dat.mcc.biz.cmm.converter.ClaimSearchConverter; +import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchRequest; +import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchResponse; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncla.MciNclaClient; +import io.shinhanlife.dat.mcc.infra.itrf.mci.ncla.io.CLCNNB00001_O; +import io.shinhanlife.glow.BizException; +import org.junit.jupiter.api.Test; + +class ClaimSearchUseCaseImplTest { + + @Test + void returnsErrorResponseWhenMciReportsABusinessCommunicationFailure() { + MciNclaClient mci = mock(MciNclaClient.class); + when(mci.callTo(eq("CLCNNB00001"), any(), any(), eq(CLCNNB00001_O.class))) + .thenThrow(bizException()); + ClaimSearchUseCaseImpl useCase = new ClaimSearchUseCaseImpl(mci, mock(ClaimSearchConverter.class)); + + ClaimSearchResponse response = useCase.searchClaim(mock(ClaimSearchRequest.class)); + + assertEquals("ERROR", response.getResultCode()); + } + + @Test + void doesNotHideUnexpectedRuntimeFailures() { + MciNclaClient mci = mock(MciNclaClient.class); + when(mci.callTo(eq("CLCNNB00001"), any(), any(), eq(CLCNNB00001_O.class))) + .thenThrow(new IllegalStateException("unexpected defect")); + ClaimSearchUseCaseImpl useCase = new ClaimSearchUseCaseImpl(mci, mock(ClaimSearchConverter.class)); + + assertThrows(IllegalStateException.class, + () -> useCase.searchClaim(mock(ClaimSearchRequest.class))); + } + + private BizException bizException() { + return new BizException("CST00477", new String[]{"MCI communication failure"}, + new RuntimeException("connection closed")); + } +} diff --git a/docs/superpowers/plans/2026-08-25-mcp-header-contract.md b/docs/superpowers/plans/2026-08-25-mcp-header-contract.md new file mode 100644 index 000000000..edc869cf3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-mcp-header-contract.md @@ -0,0 +1,73 @@ +# MCP Header Contract Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the legacy Tool Pod request headers with the approved 12-header contract and propagate every value through execution and outbound HTTP calls. + +**Architecture:** `McpRequestHeaders` is the normalized immutable carrier. Both MCP filtering and the legacy REST adapter populate it, `McpToolExecutionService` returns the transport-safe correlation headers, and `AxhubHttpComponent` forwards the complete contract to downstream HTTP services. All headers remain optional. + +**Tech Stack:** Java 21, Spring Web, Jakarta Servlet, JUnit 5, AssertJ, Gradle + +**Spec:** User-approved header list in the 2026-08-25 Codex task. + +## Global Constraints + +- Canonical headers are exactly `X-Guid`, `X-Praf-No`, `X-Request-Id`, `X-Request-Time`, `X-Vrtl-Praf-No`, `X-App-Code`, `X-Project-Code`, `X-User-Ip`, `X-Caller-Ip`, `X-Caller-Host`, `X-Channel`, and `X-Agent-Id`. +- Remove legacy `guid`, `employee-no`, and `virtual-employee-no`; do not retain aliases. +- Keep `mcp-session-id` because it is MCP transport metadata rather than a replaced business header. +- Do not reject absent headers; capture and propagate values only when present. +- Do not commit or push unless the user requests it separately. + +--- + +### Task 1: Request Capture Contract + +**Files:** +- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java` +- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java` +- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java` +- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java` +- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java` + +**Interfaces:** +- Produces: `McpRequestHeaders` accessors for all 12 canonical values plus `mcpSessionId()`. + +- [ ] Change the filter test fixture to send literal canonical headers and assert every record component. +- [ ] Change the controller contract test to assert the exact 12 canonical names plus `mcp-session-id`, all with `required=false`. +- [ ] Run the two tests and confirm failure because the record and controller still expose legacy fields. +- [ ] Expand the record and populate it from both HTTP entry points. +- [ ] Run the two tests and confirm they pass. + +### Task 2: Execution Response and Downstream Propagation + +**Files:** +- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java` +- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java` +- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java` +- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java` + +**Interfaces:** +- Consumes: the expanded `McpRequestHeaders` record from Task 1. +- Produces: response headers `X-Request-Id`, `X-Guid`, and `mcp-session-id`; downstream calls receive all 12 canonical headers plus the MCP session header. + +- [ ] Update execution and HTTP integration tests with hand-written literal values for every field. +- [ ] Run both tests and confirm failure on missing canonical propagation. +- [ ] Return canonical correlation headers and forward all present request headers. +- [ ] Run both tests and confirm they pass. + +### Task 3: Documentation and Console Contract + +**Files:** +- Modify: `README.md` +- Modify: `dat-was-lib/src/main/resources/static/tool-test-console.html` +- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java` + +**Interfaces:** +- Consumes: the canonical header names from Tasks 1 and 2. +- Produces: runnable examples and console calls that no longer send removed headers. + +- [ ] Update the console resource test to require canonical header labels and calls. +- [ ] Run the resource test and confirm failure on legacy header text. +- [ ] Update README examples/table and console request/response labels. +- [ ] Run the resource test and confirm it passes. +- [ ] Run `./gradlew.bat :dat-was-lib:test` and confirm the full module suite passes.