This commit is contained in:
7
TestClientTransport.java
Normal file
7
TestClientTransport.java
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
|
public class TestClientTransport {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
HttpClientStreamableHttpTransport t = HttpClientStreamableHttpTransport.builder("http://localhost:8086/mcp").build();
|
||||||
|
System.out.println("Wait, I can't print private fields easily, but let's just see if it runs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
TestMcpCall.java
Normal file
22
TestMcpCall.java
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
public class TestMcpCall {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
|
String json = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"iam_team_contact\",\"arguments\":{\"target\":\"전체\"}},\"id\":1}";
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("http://localhost:8086/mcp"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", "Bearer dapms") // just in case
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
System.out.println("STATUS: " + response.statusCode());
|
||||||
|
System.out.println("BODY: " + response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
39
TestMcpClient.java
Normal file
39
TestMcpClient.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
|
import io.modelcontextprotocol.client.McpSyncClient;
|
||||||
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
public class TestMcpClient {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String endpoint = "http://localhost:8086/mcp";
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
||||||
|
requestBuilder.header("Authorization", "Bearer dapms");
|
||||||
|
|
||||||
|
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(endpoint)
|
||||||
|
.requestBuilder(requestBuilder)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (McpSyncClient client = McpClient.sync(transport)
|
||||||
|
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
|
||||||
|
.requestTimeout(Duration.ofSeconds(30))
|
||||||
|
.build()) {
|
||||||
|
System.out.println("Initializing client...");
|
||||||
|
client.initialize();
|
||||||
|
System.out.println("Client initialized.");
|
||||||
|
|
||||||
|
System.out.println("Calling tool...");
|
||||||
|
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
|
||||||
|
.name("iam_team_contact")
|
||||||
|
.arguments(Map.of("target", "전체"))
|
||||||
|
.build());
|
||||||
|
System.out.println("Result: " + result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
TestReflection.java
Normal file
11
TestReflection.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import java.lang.reflect.Method;
|
||||||
|
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||||
|
|
||||||
|
public class TestReflection {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
Class<?> clazz = HttpServletStreamableServerTransportProvider.Builder.class;
|
||||||
|
for (Method m : clazz.getDeclaredMethods()) {
|
||||||
|
System.out.println(m.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
TestSseEndpoint.java
Normal file
20
TestSseEndpoint.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
public class TestSseEndpoint {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("http://localhost:8086/mcp"))
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.header("X-Tool-Server-API-Key", "test")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
System.out.println("STATUS: " + response.statusCode());
|
||||||
|
System.out.println("BODY: " + response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ package io.shinhanlife.dat.mcg.presentation;
|
|||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
import io.shinhanlife.dat.lib.util.PodScaffolder;
|
import io.shinhanlife.dat.lib.util.PodScaffolder;
|
||||||
|
import io.shinhanlife.dat.lib.util.MciResponseScaffolder;
|
||||||
import io.shinhanlife.dat.lib.util.ToolScaffolder;
|
import io.shinhanlife.dat.lib.util.ToolScaffolder;
|
||||||
import io.shinhanlife.dat.lib.util.ToolSourceUpdater;
|
import io.shinhanlife.dat.lib.util.ToolSourceUpdater;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
@@ -317,6 +318,73 @@ public class ScaffoldingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mci-response/analyze")
|
||||||
|
public ResponseEntity<?> analyzeMciResponse(@RequestBody MciResponseAnalyzeRequest request) {
|
||||||
|
if (request == null || request.source() == null || request.source().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "XXXX_O.java 소스를 입력해주세요."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(request.source());
|
||||||
|
String fieldsJson = objectMapper.writeValueAsString(parsed.types().stream()
|
||||||
|
.flatMap(type -> type.fields().stream().map(field -> Map.of(
|
||||||
|
"ownerType", type.name(),
|
||||||
|
"sourceName", field.name(),
|
||||||
|
"description", field.description(),
|
||||||
|
"javaType", field.type(),
|
||||||
|
"sensitive", field.sensitive())))
|
||||||
|
.toList());
|
||||||
|
String prompt = """
|
||||||
|
You rename legacy Korean financial-system response fields for an MCP Tool response DTO.
|
||||||
|
Return JSON only with this exact shape:
|
||||||
|
{"mappings":[{"ownerType":"SourceOwnerClass","sourceName":"legacyField","targetName":"businessMeaningInEnglish","include":true}]}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Return exactly one mapping for every input field, preserving ownerType and sourceName verbatim.
|
||||||
|
- targetName must be a concise, descriptive English Java camelCase identifier.
|
||||||
|
- Derive the business meaning primarily from description; use sourceName only as supporting metadata.
|
||||||
|
- Expand abbreviations: No -> Number, Cd -> Code, Nm -> Name, Ymd/Dt -> Date when the description supports it.
|
||||||
|
- Do not invent fields, examples, descriptions, values, or business rules.
|
||||||
|
- Keep include=true. Sensitive fields must still be named accurately; the UI will show a warning for human review.
|
||||||
|
- targetName values must be unique within each ownerType.
|
||||||
|
|
||||||
|
Source fields:
|
||||||
|
%s
|
||||||
|
""".formatted(fieldsJson);
|
||||||
|
String aiResponse = generateAiContent(prompt, request.model());
|
||||||
|
AiMciMappingDraft draft = objectMapper.readValue(stripCodeFence(aiResponse), AiMciMappingDraft.class);
|
||||||
|
List<MciResponseScaffolder.FieldMapping> mappings = normalizeAiMappings(parsed, draft);
|
||||||
|
return ResponseEntity.ok(new MciResponseAnalyzeResponse(parsed, mappings));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("error", "MCI Response AI 분석 실패: " + safeMessage(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mci-response/generate")
|
||||||
|
public ResponseEntity<?> generateMciResponse(@RequestBody MciResponseGenerateRequest request) {
|
||||||
|
if (request == null || request.source() == null || request.source().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "XXXX_O.java 소스를 입력해주세요."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(request.source());
|
||||||
|
MciResponseScaffolder.GeneratedSources generated = MciResponseScaffolder.generate(
|
||||||
|
parsed,
|
||||||
|
request.responsePackage(),
|
||||||
|
request.responseClassName(),
|
||||||
|
request.converterPackage(),
|
||||||
|
request.converterClassName(),
|
||||||
|
request.mappings());
|
||||||
|
return ResponseEntity.ok(generated);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("error", "MCI Response 소스 생성 실패: " + safeMessage(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/tool/update")
|
@PostMapping("/tool/update")
|
||||||
public String updateTool(@RequestBody Map<String, String> req) {
|
public String updateTool(@RequestBody Map<String, String> req) {
|
||||||
try {
|
try {
|
||||||
@@ -360,6 +428,39 @@ public class ScaffoldingController {
|
|||||||
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MciResponseScaffolder.FieldMapping> normalizeAiMappings(
|
||||||
|
MciResponseScaffolder.ParsedSource parsed, AiMciMappingDraft draft) {
|
||||||
|
Map<String, AiMciFieldMapping> suggestions = new java.util.LinkedHashMap<>();
|
||||||
|
if (draft != null && draft.mappings() != null) {
|
||||||
|
for (AiMciFieldMapping mapping : draft.mappings()) {
|
||||||
|
if (mapping == null || mapping.ownerType() == null || mapping.sourceName() == null) continue;
|
||||||
|
suggestions.put(mapping.ownerType() + "#" + mapping.sourceName(), mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MciResponseScaffolder.FieldMapping> result = new java.util.ArrayList<>();
|
||||||
|
Map<String, Set<String>> usedTargets = new java.util.HashMap<>();
|
||||||
|
for (MciResponseScaffolder.ParsedType type : parsed.types()) {
|
||||||
|
Set<String> used = usedTargets.computeIfAbsent(type.name(), ignored -> new LinkedHashSet<>());
|
||||||
|
for (MciResponseScaffolder.ParsedField field : type.fields()) {
|
||||||
|
AiMciFieldMapping suggestion = suggestions.get(type.name() + "#" + field.name());
|
||||||
|
String targetName = suggestion == null ? field.name() : suggestion.targetName();
|
||||||
|
targetName = targetName == null ? "" : targetName.trim();
|
||||||
|
if (!targetName.matches("^[a-z][A-Za-z0-9]*$") || used.contains(targetName)) {
|
||||||
|
targetName = field.name();
|
||||||
|
}
|
||||||
|
if (!targetName.matches("^[a-z][A-Za-z0-9]*$") || !used.add(targetName)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AI가 중복되거나 올바르지 않은 필드명을 생성했습니다: " + type.name() + "." + field.name());
|
||||||
|
}
|
||||||
|
boolean include = suggestion == null || suggestion.include() == null || suggestion.include();
|
||||||
|
result.add(new MciResponseScaffolder.FieldMapping(
|
||||||
|
type.name(), field.name(), targetName, include));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> parseDelimited(String source) {
|
private List<String> parseDelimited(String source) {
|
||||||
if (source == null || source.isBlank()) {
|
if (source == null || source.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -551,6 +652,27 @@ public class ScaffoldingController {
|
|||||||
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
|
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record MciResponseAnalyzeRequest(String source, String model) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MciResponseAnalyzeResponse(MciResponseScaffolder.ParsedSource parsed,
|
||||||
|
List<MciResponseScaffolder.FieldMapping> mappings) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MciResponseGenerateRequest(String source,
|
||||||
|
String responsePackage,
|
||||||
|
String responseClassName,
|
||||||
|
String converterPackage,
|
||||||
|
String converterClassName,
|
||||||
|
List<MciResponseScaffolder.FieldMapping> mappings) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AiMciMappingDraft(List<AiMciFieldMapping> mappings) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AiMciFieldMapping(String ownerType, String sourceName, String targetName, Boolean include) {
|
||||||
|
}
|
||||||
|
|
||||||
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
|
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
|
||||||
String httpApiName, String functionDescription, String displayDescription,
|
String httpApiName, String functionDescription, String displayDescription,
|
||||||
String whenToUse, String whenNotToUse, String ioLimits,
|
String whenToUse, String whenNotToUse, String ioLimits,
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ mcp:
|
|||||||
hr: http://was-cus:8084
|
hr: http://was-cus:8084
|
||||||
pro: http://was-pro:8085
|
pro: http://was-pro:8085
|
||||||
sys: http://was-sys:8086
|
sys: http://was-sys:8086
|
||||||
|
iam: http://was-sys:8086
|
||||||
|
|
||||||
agent-claims-required: false
|
agent-claims-required: false
|
||||||
trusted-claims-required: false
|
trusted-claims-required: false
|
||||||
write-approval-required: false
|
write-approval-required: false
|
||||||
|
|||||||
@@ -787,6 +787,9 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
|
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="mci-response-tab" data-bs-toggle="tab" data-bs-target="#mci-response" type="button" role="tab">MCI Response 변환</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button>
|
<button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -1131,6 +1134,131 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Glow MCI Response Converter -->
|
||||||
|
<div class="tab-pane fade" id="mci-response" role="tabpanel" aria-labelledby="mci-response-tab">
|
||||||
|
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:1.05rem; color:#f4f4f5; margin:0 0 0.45rem;">Glow MCI 응답을 LLM Response로 변환</h2>
|
||||||
|
<div class="input-hint mt-0">XXXX_O.java의 전문 구조와 <code>@GlowTrgmField.description</code>은 원문 그대로 읽고, AI는 축약 필드명을 업무 의미가 드러나는 영문 camelCase로만 제안합니다.</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge-mono">구조: Parser · 이름: AI · 출력: MapStruct</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="mciResponseFile">XXXX_O.java 파일</label>
|
||||||
|
<input class="form-control" type="file" id="mciResponseFile" accept=".java,text/x-java-source">
|
||||||
|
<div class="input-hint">파일을 선택하면 아래 소스 입력란에 UTF-8 텍스트로 불러옵니다. 소스를 직접 붙여넣어도 됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mciResponseAiModel">필드명 추천 AI</label>
|
||||||
|
<select id="mciResponseAiModel" class="form-select">
|
||||||
|
<optgroup label="[신한라이프 내부망]">
|
||||||
|
<option value="Qwen3-Coder" selected>Qwen3-Coder</option>
|
||||||
|
<option value="Gemma-4-31B">Gemma-4-31B</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="[외부 OpenRouter 무료]">
|
||||||
|
<option value="cohere/north-mini-code:free">Cohere North Mini Code</option>
|
||||||
|
<option value="inclusionai/ling-3.0-flash:free">InclusionAI Ling 3 Flash</option>
|
||||||
|
<option value="openai/gpt-oss-20b:free">OpenAI GPT-OSS 20B</option>
|
||||||
|
<option value="google/gemma-4-31b-it:free">Google Gemma 4 31B</option>
|
||||||
|
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron 3 Nano</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="mciResponseSource">MCI 응답 소스</label>
|
||||||
|
<textarea id="mciResponseSource" class="form-control" rows="13" spellcheck="false" placeholder="package ...; public class ONBSZ0460_O { @GlowTrgmField(order = 1, length = 8, description = "인사번호") private String prafNo; }"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mb-4">
|
||||||
|
<button id="mciResponseAnalyzeButton" type="button" class="btn-action" onclick="analyzeMciResponse()">AI로 필드명 분석</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mciResponseResultArea" style="display:none;">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mciResponsePackage">Response Package</label>
|
||||||
|
<input id="mciResponsePackage" type="text" class="form-control" value="io.shinhanlife.dat.mcc.biz.pro.dto">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mciResponseClassName">Response Class</label>
|
||||||
|
<input id="mciResponseClassName" type="text" class="form-control" placeholder="IndividualCustomerDetailInquiryResponse">
|
||||||
|
<div class="input-hint">업무 의미가 드러나는 PascalCase 이름으로 최종 확인하세요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mciConverterPackage">Converter Package</label>
|
||||||
|
<input id="mciConverterPackage" type="text" class="form-control" value="io.shinhanlife.dat.mcc.biz.pro.converter">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mciConverterClassName">Converter Class</label>
|
||||||
|
<input id="mciConverterClassName" type="text" class="form-control" placeholder="IndividualCustomerDetailInquiryConverter">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label mb-0">필드 매핑 검토</label>
|
||||||
|
<div class="input-hint mt-1">AI 제안은 직접 수정할 수 있습니다. description은 Glow 원문 그대로 Response의 <code>@Schema</code>에 반영됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<span id="mciResponseFieldCount" class="badge-mono"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive rounded" style="border:1px solid #3f3f46;">
|
||||||
|
<table class="table mb-0" style="color:#d4d4d8; font-size:0.82rem;">
|
||||||
|
<thead style="background:#18181b; color:#a1a1aa;">
|
||||||
|
<tr>
|
||||||
|
<th style="width:7%;">사용</th>
|
||||||
|
<th style="width:17%;">소유 타입</th>
|
||||||
|
<th style="width:15%;">전문 필드</th>
|
||||||
|
<th style="width:22%;">Glow 설명</th>
|
||||||
|
<th style="width:23%;">LLM 필드명</th>
|
||||||
|
<th style="width:10%;">Java 타입</th>
|
||||||
|
<th style="width:6%;">확인</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mciResponseMappingBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mb-4">
|
||||||
|
<button id="mciResponseGenerateButton" type="button" class="btn-action" onclick="generateMciResponseSources()">Response / Converter 미리보기 생성</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mciResponsePreviewArea" style="display:none;">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label class="form-label mb-0">Response.java</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="copyMciResponseSource('mciResponsePreview')">복사</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="downloadMciResponseSource('response')">다운로드</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="mciResponsePreview" class="form-control" rows="22" spellcheck="false" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label class="form-label mb-0">Converter.java</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="copyMciResponseSource('mciConverterPreview')">복사</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="downloadMciResponseSource('converter')">다운로드</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="mciConverterPreview" class="form-control" rows="22" spellcheck="false" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mciResponseStatus" class="input-hint mt-3" role="status" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Document Generator -->
|
<!-- Document Generator -->
|
||||||
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
|
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
|
||||||
<div class="docgen-workspace">
|
<div class="docgen-workspace">
|
||||||
@@ -2483,6 +2611,288 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const mciResponseState = {
|
||||||
|
parsed: null,
|
||||||
|
responseSource: '',
|
||||||
|
converterSource: '',
|
||||||
|
autoConverterName: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('mciResponseFile').addEventListener('change', async function() {
|
||||||
|
const file = this.files && this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
document.getElementById('mciResponseSource').value = (await file.text()).replace(/^\uFEFF/, '');
|
||||||
|
setMciResponseStatus(`${file.name} 파일을 불러왔습니다.`, false);
|
||||||
|
} catch (error) {
|
||||||
|
setMciResponseStatus(`파일을 읽지 못했습니다: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mciResponseClassName').addEventListener('input', function() {
|
||||||
|
const converterInput = document.getElementById('mciConverterClassName');
|
||||||
|
if (!converterInput.value || converterInput.value === mciResponseState.autoConverterName) {
|
||||||
|
mciResponseState.autoConverterName = converterNameFor(this.value);
|
||||||
|
converterInput.value = mciResponseState.autoConverterName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mciResponsePackage').addEventListener('input', function() {
|
||||||
|
const converterPackage = document.getElementById('mciConverterPackage');
|
||||||
|
if (this.value.endsWith('.dto')) converterPackage.value = this.value.slice(0, -4) + '.converter';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function analyzeMciResponse() {
|
||||||
|
const source = document.getElementById('mciResponseSource').value.trim();
|
||||||
|
if (!source) {
|
||||||
|
setMciResponseStatus('XXXX_O.java 파일을 선택하거나 소스를 붙여넣어 주세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.getElementById('mciResponseAnalyzeButton');
|
||||||
|
setMciResponseButtonBusy(button, true, 'AI 분석 중...');
|
||||||
|
setMciResponseStatus('Glow 전문 구조를 분석하고 AI가 LLM 필드명을 제안하고 있습니다.', false);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/scaffold/mci-response/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
source,
|
||||||
|
model: document.getElementById('mciResponseAiModel').value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || `AI 분석 실패 (HTTP ${response.status})`);
|
||||||
|
mciResponseState.parsed = result.parsed;
|
||||||
|
mciResponseState.responseSource = '';
|
||||||
|
mciResponseState.converterSource = '';
|
||||||
|
initializeMciResponseNames(result.parsed);
|
||||||
|
renderMciResponseMappings(result.parsed, result.mappings || []);
|
||||||
|
document.getElementById('mciResponseResultArea').style.display = 'block';
|
||||||
|
document.getElementById('mciResponsePreviewArea').style.display = 'none';
|
||||||
|
setMciResponseStatus('AI 제안을 불러왔습니다. 필드명과 개인정보 표시를 검토한 뒤 미리보기를 생성하세요.', false);
|
||||||
|
} catch (error) {
|
||||||
|
setMciResponseStatus(error.message, true);
|
||||||
|
} finally {
|
||||||
|
setMciResponseButtonBusy(button, false, 'AI로 필드명 분석');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeMciResponseNames(parsed) {
|
||||||
|
const rootName = parsed?.rootClassName || 'MciOutput';
|
||||||
|
const baseName = rootName.replace(/_O$/, '') || 'MciOutput';
|
||||||
|
const responseName = `${baseName}Response`;
|
||||||
|
const converterName = `${baseName}Converter`;
|
||||||
|
document.getElementById('mciResponseClassName').value = responseName;
|
||||||
|
document.getElementById('mciConverterClassName').value = converterName;
|
||||||
|
mciResponseState.autoConverterName = converterName;
|
||||||
|
|
||||||
|
const sourcePackage = parsed?.packageName || '';
|
||||||
|
const applicationRoot = sourcePackage.includes('.infra.')
|
||||||
|
? sourcePackage.substring(0, sourcePackage.indexOf('.infra.')) : '';
|
||||||
|
if (applicationRoot) {
|
||||||
|
document.getElementById('mciResponsePackage').value = `${applicationRoot}.biz.pro.dto`;
|
||||||
|
document.getElementById('mciConverterPackage').value = `${applicationRoot}.biz.pro.converter`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMciResponseMappings(parsed, mappings) {
|
||||||
|
const tbody = document.getElementById('mciResponseMappingBody');
|
||||||
|
tbody.replaceChildren();
|
||||||
|
const mappingByKey = new Map(mappings.map(mapping => [
|
||||||
|
`${mapping.ownerType}#${mapping.sourceName}`, mapping
|
||||||
|
]));
|
||||||
|
let fieldCount = 0;
|
||||||
|
let sensitiveCount = 0;
|
||||||
|
(parsed.types || []).forEach(type => {
|
||||||
|
(type.fields || []).forEach(field => {
|
||||||
|
fieldCount++;
|
||||||
|
if (field.sensitive) sensitiveCount++;
|
||||||
|
const mapping = mappingByKey.get(`${type.name}#${field.name}`) || {
|
||||||
|
ownerType: type.name,
|
||||||
|
sourceName: field.name,
|
||||||
|
targetName: field.name,
|
||||||
|
include: true
|
||||||
|
};
|
||||||
|
tbody.appendChild(createMciResponseMappingRow(type, field, mapping));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('mciResponseFieldCount').textContent = sensitiveCount
|
||||||
|
? `${fieldCount}개 필드 · 개인정보 후보 ${sensitiveCount}개`
|
||||||
|
: `${fieldCount}개 필드`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMciResponseMappingRow(type, field, mapping) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.dataset.ownerType = type.name;
|
||||||
|
row.dataset.sourceName = field.name;
|
||||||
|
|
||||||
|
const includeCell = document.createElement('td');
|
||||||
|
const include = document.createElement('input');
|
||||||
|
include.type = 'checkbox';
|
||||||
|
include.className = 'form-check-input mci-response-include';
|
||||||
|
include.checked = mapping.include !== false;
|
||||||
|
includeCell.appendChild(include);
|
||||||
|
|
||||||
|
const ownerCell = document.createElement('td');
|
||||||
|
ownerCell.textContent = type.name;
|
||||||
|
const sourceCell = document.createElement('td');
|
||||||
|
const sourceCode = document.createElement('code');
|
||||||
|
sourceCode.textContent = field.name;
|
||||||
|
sourceCell.appendChild(sourceCode);
|
||||||
|
const descriptionCell = document.createElement('td');
|
||||||
|
descriptionCell.textContent = field.description || '(설명 없음)';
|
||||||
|
|
||||||
|
const targetCell = document.createElement('td');
|
||||||
|
const targetInput = document.createElement('input');
|
||||||
|
targetInput.type = 'text';
|
||||||
|
targetInput.className = 'form-control form-control-sm mci-response-target';
|
||||||
|
targetInput.value = mapping.targetName || field.name;
|
||||||
|
targetInput.pattern = '^[a-z][A-Za-z0-9]*$';
|
||||||
|
targetInput.title = '영문 소문자로 시작하는 camelCase Java 필드명을 입력하세요.';
|
||||||
|
targetInput.addEventListener('input', validateMciResponseMappingRows);
|
||||||
|
targetCell.appendChild(targetInput);
|
||||||
|
|
||||||
|
const typeCell = document.createElement('td');
|
||||||
|
typeCell.textContent = field.type;
|
||||||
|
const warningCell = document.createElement('td');
|
||||||
|
if (field.sensitive) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge-mono';
|
||||||
|
badge.style.color = '#fca5a5';
|
||||||
|
badge.style.borderColor = '#7f1d1d';
|
||||||
|
badge.style.background = 'rgba(127,29,29,0.22)';
|
||||||
|
badge.textContent = 'PII';
|
||||||
|
badge.title = '개인정보 가능성이 있는 필드입니다. Tool 응답 노출 여부를 반드시 확인하세요.';
|
||||||
|
warningCell.appendChild(badge);
|
||||||
|
} else {
|
||||||
|
warningCell.textContent = '-';
|
||||||
|
}
|
||||||
|
[includeCell, ownerCell, sourceCell, descriptionCell, targetCell, typeCell, warningCell]
|
||||||
|
.forEach(cell => row.appendChild(cell));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMciResponseMappingRows() {
|
||||||
|
const usedByOwner = new Map();
|
||||||
|
let valid = true;
|
||||||
|
document.querySelectorAll('#mciResponseMappingBody tr').forEach(row => {
|
||||||
|
const input = row.querySelector('.mci-response-target');
|
||||||
|
const include = row.querySelector('.mci-response-include').checked;
|
||||||
|
input.classList.remove('is-invalid');
|
||||||
|
if (!include) return;
|
||||||
|
const name = input.value.trim();
|
||||||
|
const owner = row.dataset.ownerType;
|
||||||
|
const used = usedByOwner.get(owner) || new Set();
|
||||||
|
if (!/^[a-z][A-Za-z0-9]*$/.test(name) || used.has(name)) {
|
||||||
|
input.classList.add('is-invalid');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
used.add(name);
|
||||||
|
usedByOwner.set(owner, used);
|
||||||
|
});
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMciResponseMappings() {
|
||||||
|
if (!validateMciResponseMappingRows()) {
|
||||||
|
throw new Error('빨간색으로 표시된 LLM 필드명을 수정하세요. camelCase 형식이며 같은 타입 안에서 중복될 수 없습니다.');
|
||||||
|
}
|
||||||
|
return Array.from(document.querySelectorAll('#mciResponseMappingBody tr')).map(row => ({
|
||||||
|
ownerType: row.dataset.ownerType,
|
||||||
|
sourceName: row.dataset.sourceName,
|
||||||
|
targetName: row.querySelector('.mci-response-target').value.trim(),
|
||||||
|
include: row.querySelector('.mci-response-include').checked
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateMciResponseSources() {
|
||||||
|
if (!mciResponseState.parsed) {
|
||||||
|
setMciResponseStatus('먼저 AI 필드명 분석을 실행하세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.getElementById('mciResponseGenerateButton');
|
||||||
|
setMciResponseButtonBusy(button, true, '소스 생성 중...');
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
source: document.getElementById('mciResponseSource').value,
|
||||||
|
responsePackage: document.getElementById('mciResponsePackage').value.trim(),
|
||||||
|
responseClassName: document.getElementById('mciResponseClassName').value.trim(),
|
||||||
|
converterPackage: document.getElementById('mciConverterPackage').value.trim(),
|
||||||
|
converterClassName: document.getElementById('mciConverterClassName').value.trim(),
|
||||||
|
mappings: collectMciResponseMappings()
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/v1/scaffold/mci-response/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || `소스 생성 실패 (HTTP ${response.status})`);
|
||||||
|
mciResponseState.responseSource = result.responseSource || '';
|
||||||
|
mciResponseState.converterSource = result.converterSource || '';
|
||||||
|
document.getElementById('mciResponsePreview').value = mciResponseState.responseSource;
|
||||||
|
document.getElementById('mciConverterPreview').value = mciResponseState.converterSource;
|
||||||
|
document.getElementById('mciResponsePreviewArea').style.display = 'block';
|
||||||
|
setMciResponseStatus('Response와 MapStruct Converter를 생성했습니다. 미리보기 검토 후 다운로드하세요.', false);
|
||||||
|
} catch (error) {
|
||||||
|
setMciResponseStatus(error.message, true);
|
||||||
|
} finally {
|
||||||
|
setMciResponseButtonBusy(button, false, 'Response / Converter 미리보기 생성');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMciResponseSource(elementId) {
|
||||||
|
const textarea = document.getElementById(elementId);
|
||||||
|
if (!textarea.value) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textarea.value);
|
||||||
|
} catch (_) {
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
textarea.setSelectionRange(0, 0);
|
||||||
|
}
|
||||||
|
setMciResponseStatus('소스를 클립보드에 복사했습니다.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadMciResponseSource(kind) {
|
||||||
|
const response = kind === 'response';
|
||||||
|
const source = response ? mciResponseState.responseSource : mciResponseState.converterSource;
|
||||||
|
const className = document.getElementById(response ? 'mciResponseClassName' : 'mciConverterClassName').value.trim();
|
||||||
|
if (!source || !className) {
|
||||||
|
setMciResponseStatus('먼저 소스 미리보기를 생성하세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([source], {type: 'text/x-java-source;charset=utf-8'});
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = `${className}.java`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function converterNameFor(responseName) {
|
||||||
|
return responseName && responseName.endsWith('Response')
|
||||||
|
? responseName.slice(0, -8) + 'Converter'
|
||||||
|
: (responseName ? responseName + 'Converter' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMciResponseButtonBusy(button, busy, text) {
|
||||||
|
button.disabled = busy;
|
||||||
|
button.textContent = text;
|
||||||
|
button.style.opacity = busy ? '0.7' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMciResponseStatus(message, error) {
|
||||||
|
const status = document.getElementById('mciResponseStatus');
|
||||||
|
status.textContent = message || '';
|
||||||
|
status.style.color = error ? '#f87171' : '#a1a1aa';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function openServerFolderPicker(inputId) {
|
function openServerFolderPicker(inputId) {
|
||||||
|
|||||||
@@ -115,6 +115,85 @@ class ScaffoldingControllerToolDraftTest {
|
|||||||
assertTrue(promptCaptor.getValue().contains("Default to MCI"));
|
assertTrue(promptCaptor.getValue().contains("Default to MCI"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mciResponseAnalyzeKeepsGlowDescriptionAndUsesAiForTheLlmFieldName() throws Exception {
|
||||||
|
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
||||||
|
ChatClient chatClient = mock(ChatClient.class);
|
||||||
|
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
|
||||||
|
ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class);
|
||||||
|
when(builder.build()).thenReturn(chatClient);
|
||||||
|
when(chatClient.prompt()).thenReturn(requestSpec);
|
||||||
|
when(requestSpec.user(anyString())).thenReturn(requestSpec);
|
||||||
|
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
|
||||||
|
when(requestSpec.call()).thenReturn(responseSpec);
|
||||||
|
when(responseSpec.content()).thenReturn("""
|
||||||
|
{"mappings":[{"ownerType":"ONBSZ0460_O","sourceName":"prafNo","targetName":"employeeNumber","include":true}]}
|
||||||
|
""");
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new ScaffoldingController(builder, new ObjectMapper()))
|
||||||
|
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||||
|
.build();
|
||||||
|
String source = """
|
||||||
|
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
|
||||||
|
public class ONBSZ0460_O {
|
||||||
|
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
|
||||||
|
private String prafNo;
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
String request = new ObjectMapper().writeValueAsString(java.util.Map.of(
|
||||||
|
"source", source,
|
||||||
|
"model", "cohere/north-mini-code:free"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/mci-response/analyze")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(request))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.parsed.rootClassName").value("ONBSZ0460_O"))
|
||||||
|
.andExpect(jsonPath("$.parsed.types[0].fields[0].description").value("인사번호"))
|
||||||
|
.andExpect(jsonPath("$.mappings[0].sourceName").value("prafNo"))
|
||||||
|
.andExpect(jsonPath("$.mappings[0].targetName").value("employeeNumber"));
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<String> promptCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(requestSpec).user(promptCaptor.capture());
|
||||||
|
assertTrue(promptCaptor.getValue().contains("인사번호"));
|
||||||
|
assertTrue(promptCaptor.getValue().contains("prafNo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mciResponseGenerateReturnsResponseAndConverterPreviews() throws Exception {
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper()))
|
||||||
|
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||||
|
.build();
|
||||||
|
String source = """
|
||||||
|
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
|
||||||
|
public class ONBSZ0460_O {
|
||||||
|
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
|
||||||
|
private String prafNo;
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
String request = new ObjectMapper().writeValueAsString(java.util.Map.of(
|
||||||
|
"source", source,
|
||||||
|
"responsePackage", "io.shinhanlife.dat.mcc.biz.pro.dto",
|
||||||
|
"responseClassName", "IndividualCustomerDetailInquiryResponse",
|
||||||
|
"converterPackage", "io.shinhanlife.dat.mcc.biz.pro.converter",
|
||||||
|
"converterClassName", "IndividualCustomerDetailInquiryConverter",
|
||||||
|
"mappings", java.util.List.of(java.util.Map.of(
|
||||||
|
"ownerType", "ONBSZ0460_O",
|
||||||
|
"sourceName", "prafNo",
|
||||||
|
"targetName", "employeeNumber",
|
||||||
|
"include", true))));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/mci-response/generate")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(request))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.responseSource", org.hamcrest.Matchers.containsString(
|
||||||
|
"@Schema(description = \"인사번호\")")))
|
||||||
|
.andExpect(jsonPath("$.responseSource", org.hamcrest.Matchers.containsString(
|
||||||
|
"private String employeeNumber;")))
|
||||||
|
.andExpect(jsonPath("$.converterSource", org.hamcrest.Matchers.containsString(
|
||||||
|
"@Mapping(source = \"prafNo\", target = \"employeeNumber\")")));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
|
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
|
||||||
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
registration.setFilter(new MdcLoggingFilter());
|
registration.setFilter(new MdcLoggingFilter());
|
||||||
registration.addUrlPatterns("/*");
|
registration.addUrlPatterns("/*");
|
||||||
registration.setOrder(1);
|
registration.setOrder(1);
|
||||||
|
registration.setAsyncSupported(true);
|
||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
registration.setFilter(new McpRequestHeaderFilter(sessionService));
|
registration.setFilter(new McpRequestHeaderFilter(sessionService));
|
||||||
registration.addUrlPatterns("/*");
|
registration.addUrlPatterns("/*");
|
||||||
registration.setOrder(2);
|
registration.setOrder(2);
|
||||||
|
registration.setAsyncSupported(true);
|
||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package io.shinhanlife.dat.lib.util;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glow MCI 응답 전문({@code *_O.java})을 분석하여 LLM 노출용 Response DTO와
|
||||||
|
* MapStruct Converter 소스를 생성합니다. AI는 필드명 제안에만 사용하고 전문 구조와
|
||||||
|
* {@code GlowTrgmField.description}은 이 클래스가 원문에서 직접 추출합니다.
|
||||||
|
*/
|
||||||
|
public final class MciResponseScaffolder {
|
||||||
|
|
||||||
|
private static final Pattern PACKAGE_PATTERN = Pattern.compile(
|
||||||
|
"(?m)^\\s*package\\s+([A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*)\\s*;");
|
||||||
|
private static final Pattern CLASS_PATTERN = Pattern.compile(
|
||||||
|
"\\bclass\\s+([A-Za-z_$][\\w$]*)\\b");
|
||||||
|
private static final Pattern FIELD_PATTERN = Pattern.compile(
|
||||||
|
"(?s)((?:@[A-Za-z_$][\\w.$]*(?:\\s*\\([^;{}]*?\\))?\\s*)*)"
|
||||||
|
+ "(?:private|protected|public)\\s+"
|
||||||
|
+ "(?:(?:static|final|transient|volatile)\\s+)*"
|
||||||
|
+ "([A-Za-z_$][\\w.$]*(?:\\s*<[^;{}=]+>)?(?:\\s*\\[\\])?)\\s+"
|
||||||
|
+ "([A-Za-z_$][\\w$]*)\\s*(?:=[^;{}]*)?;");
|
||||||
|
private static final Pattern ORDER_PATTERN = Pattern.compile("\\border\\s*=\\s*(\\d+)");
|
||||||
|
private static final Pattern LENGTH_PATTERN = Pattern.compile("\\blength\\s*=\\s*(\\d+)");
|
||||||
|
private static final Pattern DESCRIPTION_PATTERN = Pattern.compile(
|
||||||
|
"\\bdescription\\s*=\\s*\"((?:\\\\.|[^\"\\\\])*)\"");
|
||||||
|
private static final Pattern JAVA_NAME_PATTERN = Pattern.compile("^[a-z][A-Za-z0-9]*$");
|
||||||
|
private static final Pattern PACKAGE_NAME_PATTERN = Pattern.compile(
|
||||||
|
"^[a-z_][a-z0-9_]*(?:\\.[a-z_][a-z0-9_]*)*$");
|
||||||
|
private static final Pattern CLASS_NAME_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]*$");
|
||||||
|
private static final List<String> SENSITIVE_KEYWORDS = List.of(
|
||||||
|
"주민등록", "주민번호", "외국인등록", "여권번호", "계좌번호", "카드번호",
|
||||||
|
"비밀번호", "암호", "resident registration", "social security", "passport", "password");
|
||||||
|
|
||||||
|
private MciResponseScaffolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ParsedSource(String packageName, String rootClassName, List<ParsedType> types) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ParsedType(String name, String parentName, List<ParsedField> fields) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ParsedField(String ownerType, int order, int length, String type, String name,
|
||||||
|
String description, boolean sensitive) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FieldMapping(String ownerType, String sourceName, String targetName, boolean include) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GeneratedSources(String responseSource, String converterSource) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ParsedSource parse(String source) {
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("MCI 응답 Java 소스를 입력해주세요.");
|
||||||
|
}
|
||||||
|
String normalized = source.replace("\uFEFF", "");
|
||||||
|
Matcher packageMatcher = PACKAGE_PATTERN.matcher(normalized);
|
||||||
|
if (!packageMatcher.find()) {
|
||||||
|
throw new IllegalArgumentException("package 선언을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClassRange> ranges = findClassRanges(normalized);
|
||||||
|
if (ranges.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Java class 선언을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
ClassRange root = ranges.stream()
|
||||||
|
.filter(range -> range.parentName() == null && range.name().endsWith("_O"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseGet(() -> ranges.stream().filter(range -> range.parentName() == null)
|
||||||
|
.findFirst().orElse(ranges.getFirst()));
|
||||||
|
|
||||||
|
List<ParsedType> types = new ArrayList<>();
|
||||||
|
types.add(new ParsedType(root.name(), null, parseFields(normalized, root)));
|
||||||
|
ranges.stream()
|
||||||
|
.filter(range -> range != root && isDescendantOf(range, root, ranges))
|
||||||
|
.sorted(Comparator.comparingInt(ClassRange::openBrace))
|
||||||
|
.forEach(range -> types.add(new ParsedType(
|
||||||
|
range.name(), range.parentName(), parseFields(normalized, range))));
|
||||||
|
|
||||||
|
if (types.stream().allMatch(type -> type.fields().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("변환할 응답 필드를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
return new ParsedSource(packageMatcher.group(1), root.name(), List.copyOf(types));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GeneratedSources generate(ParsedSource parsed, String responsePackage,
|
||||||
|
String responseClassName, String converterPackage,
|
||||||
|
String converterClassName, List<FieldMapping> mappings) {
|
||||||
|
if (parsed == null || parsed.types() == null || parsed.types().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("분석된 MCI 응답 정보가 없습니다.");
|
||||||
|
}
|
||||||
|
validatePackage(responsePackage, "Response package");
|
||||||
|
validatePackage(converterPackage, "Converter package");
|
||||||
|
validateClassName(responseClassName, "Response class");
|
||||||
|
validateClassName(converterClassName, "Converter class");
|
||||||
|
|
||||||
|
Map<String, FieldMapping> mappingBySource = normalizeMappings(parsed, mappings);
|
||||||
|
validateTargetNames(parsed, mappingBySource);
|
||||||
|
return new GeneratedSources(
|
||||||
|
responseSource(parsed, responsePackage, responseClassName, mappingBySource),
|
||||||
|
converterSource(parsed, responsePackage, responseClassName, converterPackage,
|
||||||
|
converterClassName, mappingBySource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ClassRange> findClassRanges(String source) {
|
||||||
|
List<ClassRangeDraft> drafts = new ArrayList<>();
|
||||||
|
Matcher matcher = CLASS_PATTERN.matcher(source);
|
||||||
|
while (matcher.find()) {
|
||||||
|
int openBrace = source.indexOf('{', matcher.end());
|
||||||
|
if (openBrace < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int closeBrace = matchingBrace(source, openBrace);
|
||||||
|
if (closeBrace > openBrace) {
|
||||||
|
drafts.add(new ClassRangeDraft(matcher.group(1), openBrace, closeBrace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drafts.sort(Comparator.comparingInt(ClassRangeDraft::openBrace));
|
||||||
|
List<ClassRange> ranges = new ArrayList<>();
|
||||||
|
for (ClassRangeDraft draft : drafts) {
|
||||||
|
String parent = drafts.stream()
|
||||||
|
.filter(candidate -> candidate != draft
|
||||||
|
&& candidate.openBrace() < draft.openBrace()
|
||||||
|
&& candidate.closeBrace() > draft.closeBrace())
|
||||||
|
.min(Comparator.comparingInt(candidate -> candidate.closeBrace() - candidate.openBrace()))
|
||||||
|
.map(ClassRangeDraft::name)
|
||||||
|
.orElse(null);
|
||||||
|
ranges.add(new ClassRange(draft.name(), parent, draft.openBrace(), draft.closeBrace()));
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDescendantOf(ClassRange range, ClassRange root, List<ClassRange> ranges) {
|
||||||
|
if (range.openBrace() <= root.openBrace() || range.closeBrace() >= root.closeBrace()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String parent = range.parentName();
|
||||||
|
while (parent != null) {
|
||||||
|
if (parent.equals(root.name())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String current = parent;
|
||||||
|
parent = ranges.stream().filter(candidate -> candidate.name().equals(current))
|
||||||
|
.map(ClassRange::parentName).findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ParsedField> parseFields(String source, ClassRange range) {
|
||||||
|
String body = source.substring(range.openBrace() + 1, range.closeBrace());
|
||||||
|
Matcher matcher = FIELD_PATTERN.matcher(body);
|
||||||
|
List<FieldWithPosition> fields = new ArrayList<>();
|
||||||
|
while (matcher.find()) {
|
||||||
|
int absoluteStart = range.openBrace() + 1 + matcher.start();
|
||||||
|
if (braceDepth(source, range.openBrace() + 1, absoluteStart) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String annotations = matcher.group(1) == null ? "" : matcher.group(1);
|
||||||
|
String type = matcher.group(2).replaceAll("\\s+", "");
|
||||||
|
String name = matcher.group(3);
|
||||||
|
int order = intAttribute(annotations, ORDER_PATTERN, Integer.MAX_VALUE);
|
||||||
|
int length = intAttribute(annotations, LENGTH_PATTERN, 0);
|
||||||
|
String description = stringAttribute(annotations, DESCRIPTION_PATTERN);
|
||||||
|
fields.add(new FieldWithPosition(absoluteStart, new ParsedField(
|
||||||
|
range.name(), order, length, type, name, description, isSensitive(name, description))));
|
||||||
|
}
|
||||||
|
fields.sort(Comparator
|
||||||
|
.comparingInt((FieldWithPosition value) -> value.field().order())
|
||||||
|
.thenComparingInt(FieldWithPosition::position));
|
||||||
|
return fields.stream().map(FieldWithPosition::field).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intAttribute(String source, Pattern pattern, int defaultValue) {
|
||||||
|
Matcher matcher = pattern.matcher(source);
|
||||||
|
return matcher.find() ? Integer.parseInt(matcher.group(1)) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stringAttribute(String source, Pattern pattern) {
|
||||||
|
Matcher matcher = pattern.matcher(source);
|
||||||
|
return matcher.find() ? unescapeJavaString(matcher.group(1)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String unescapeJavaString(String value) {
|
||||||
|
return value.replace("\\\"", "\"")
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace("\\r", "\r")
|
||||||
|
.replace("\\t", "\t")
|
||||||
|
.replace("\\\\", "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSensitive(String name, String description) {
|
||||||
|
String text = (name + " " + description).toLowerCase(Locale.ROOT);
|
||||||
|
return SENSITIVE_KEYWORDS.stream().anyMatch(text::contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int matchingBrace(String source, int openBrace) {
|
||||||
|
int depth = 0;
|
||||||
|
ScanState state = ScanState.CODE;
|
||||||
|
for (int i = openBrace; i < source.length(); i++) {
|
||||||
|
char current = source.charAt(i);
|
||||||
|
char next = i + 1 < source.length() ? source.charAt(i + 1) : '\0';
|
||||||
|
if (state == ScanState.CODE) {
|
||||||
|
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
|
||||||
|
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
|
||||||
|
if (current == '"') { state = ScanState.STRING; continue; }
|
||||||
|
if (current == '\'') { state = ScanState.CHAR; continue; }
|
||||||
|
if (current == '{') depth++;
|
||||||
|
if (current == '}' && --depth == 0) return i;
|
||||||
|
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
|
||||||
|
state = ScanState.CODE; i++;
|
||||||
|
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
|
||||||
|
i++;
|
||||||
|
} else if (state == ScanState.STRING && current == '"') {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
} else if (state == ScanState.CHAR && current == '\'') {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int braceDepth(String source, int from, int to) {
|
||||||
|
int depth = 0;
|
||||||
|
ScanState state = ScanState.CODE;
|
||||||
|
for (int i = from; i < to; i++) {
|
||||||
|
char current = source.charAt(i);
|
||||||
|
char next = i + 1 < to ? source.charAt(i + 1) : '\0';
|
||||||
|
if (state == ScanState.CODE) {
|
||||||
|
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
|
||||||
|
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
|
||||||
|
if (current == '"') { state = ScanState.STRING; continue; }
|
||||||
|
if (current == '\'') { state = ScanState.CHAR; continue; }
|
||||||
|
if (current == '{') depth++;
|
||||||
|
if (current == '}') depth--;
|
||||||
|
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
|
||||||
|
state = ScanState.CODE; i++;
|
||||||
|
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
|
||||||
|
i++;
|
||||||
|
} else if (state == ScanState.STRING && current == '"') {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
} else if (state == ScanState.CHAR && current == '\'') {
|
||||||
|
state = ScanState.CODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, FieldMapping> normalizeMappings(ParsedSource parsed, List<FieldMapping> mappings) {
|
||||||
|
Map<String, FieldMapping> normalized = new LinkedHashMap<>();
|
||||||
|
if (mappings != null) {
|
||||||
|
for (FieldMapping mapping : mappings) {
|
||||||
|
if (mapping == null || mapping.ownerType() == null || mapping.sourceName() == null) continue;
|
||||||
|
normalized.put(key(mapping.ownerType(), mapping.sourceName()), mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ParsedType type : parsed.types()) {
|
||||||
|
for (ParsedField field : type.fields()) {
|
||||||
|
normalized.putIfAbsent(key(type.name(), field.name()),
|
||||||
|
new FieldMapping(type.name(), field.name(), field.name(), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTargetNames(ParsedSource parsed, Map<String, FieldMapping> mappings) {
|
||||||
|
for (ParsedType type : parsed.types()) {
|
||||||
|
Set<String> targets = new HashSet<>();
|
||||||
|
for (ParsedField field : type.fields()) {
|
||||||
|
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||||
|
if (!mapping.include()) continue;
|
||||||
|
String target = mapping.targetName() == null ? "" : mapping.targetName().trim();
|
||||||
|
if (!JAVA_NAME_PATTERN.matcher(target).matches()) {
|
||||||
|
throw new IllegalArgumentException("올바르지 않은 LLM 필드명: " + target);
|
||||||
|
}
|
||||||
|
if (!targets.add(target)) {
|
||||||
|
throw new IllegalArgumentException(type.name() + " 안에 중복 LLM 필드명이 있습니다: " + target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String responseSource(ParsedSource parsed, String responsePackage, String responseClassName,
|
||||||
|
Map<String, FieldMapping> mappings) {
|
||||||
|
boolean usesList = parsed.types().stream().flatMap(type -> type.fields().stream())
|
||||||
|
.anyMatch(field -> field.type().contains("List<"));
|
||||||
|
boolean usesBigDecimal = parsed.types().stream().flatMap(type -> type.fields().stream())
|
||||||
|
.anyMatch(field -> field.type().contains("BigDecimal"));
|
||||||
|
StringBuilder source = new StringBuilder("package ").append(responsePackage).append(";\n\n")
|
||||||
|
.append("import com.fasterxml.jackson.annotation.JsonInclude;\n")
|
||||||
|
.append("import io.swagger.v3.oas.annotations.media.Schema;\n")
|
||||||
|
.append("import lombok.Data;\n");
|
||||||
|
if (usesBigDecimal) source.append("import java.math.BigDecimal;\n");
|
||||||
|
if (usesList) source.append("import java.util.List;\n");
|
||||||
|
source.append("\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\n")
|
||||||
|
.append("public class ").append(responseClassName).append(" {\n\n")
|
||||||
|
.append(responseFields(parsed.types().getFirst(), mappings, " "));
|
||||||
|
|
||||||
|
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
|
||||||
|
source.append(" @Data\n")
|
||||||
|
.append(" @JsonInclude(JsonInclude.Include.NON_NULL)\n")
|
||||||
|
.append(" public static class ").append(type.name()).append(" {\n\n")
|
||||||
|
.append(responseFields(type, mappings, " "))
|
||||||
|
.append(" }\n\n");
|
||||||
|
}
|
||||||
|
return source.append("}\n").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String responseFields(ParsedType type, Map<String, FieldMapping> mappings, String indent) {
|
||||||
|
StringBuilder source = new StringBuilder();
|
||||||
|
for (ParsedField field : type.fields()) {
|
||||||
|
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||||
|
if (!mapping.include()) continue;
|
||||||
|
source.append(indent).append("@Schema(description = \"")
|
||||||
|
.append(escapeJava(field.description())).append("\")\n")
|
||||||
|
.append(indent).append("private ").append(field.type()).append(' ')
|
||||||
|
.append(mapping.targetName().trim()).append(";\n\n");
|
||||||
|
}
|
||||||
|
return source.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String converterSource(ParsedSource parsed, String responsePackage, String responseClassName,
|
||||||
|
String converterPackage, String converterClassName,
|
||||||
|
Map<String, FieldMapping> mappings) {
|
||||||
|
StringBuilder source = new StringBuilder("package ").append(converterPackage).append(";\n\n")
|
||||||
|
.append("import ").append(responsePackage).append('.').append(responseClassName).append(";\n")
|
||||||
|
.append("import ").append(parsed.packageName()).append('.').append(parsed.rootClassName()).append(";\n")
|
||||||
|
.append("import org.mapstruct.Mapper;\n")
|
||||||
|
.append("import org.mapstruct.Mapping;\n")
|
||||||
|
.append("import org.mapstruct.ReportingPolicy;\n\n")
|
||||||
|
.append("@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\n")
|
||||||
|
.append("public interface ").append(converterClassName).append(" {\n\n");
|
||||||
|
|
||||||
|
appendMappingMethod(source, parsed.types().getFirst(), responseClassName,
|
||||||
|
parsed.rootClassName(), "toResponse", mappings);
|
||||||
|
Map<String, ParsedType> typeByName = new HashMap<>();
|
||||||
|
parsed.types().forEach(type -> typeByName.put(type.name(), type));
|
||||||
|
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
|
||||||
|
appendMappingMethod(source, type, responseClassName + "." + type.name(),
|
||||||
|
sourceTypePath(parsed.rootClassName(), type, typeByName), "to" + type.name(), mappings);
|
||||||
|
}
|
||||||
|
return source.append("}\n").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendMappingMethod(StringBuilder source, ParsedType type, String targetType,
|
||||||
|
String sourceType, String methodName,
|
||||||
|
Map<String, FieldMapping> mappings) {
|
||||||
|
for (ParsedField field : type.fields()) {
|
||||||
|
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
|
||||||
|
if (mapping.include() && !field.name().equals(mapping.targetName())) {
|
||||||
|
source.append(" @Mapping(source = \"").append(field.name())
|
||||||
|
.append("\", target = \"").append(mapping.targetName()).append("\")\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.append(" ").append(targetType).append(' ').append(methodName)
|
||||||
|
.append('(').append(sourceType).append(" source);\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sourceTypePath(String rootClassName, ParsedType type,
|
||||||
|
Map<String, ParsedType> typeByName) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
ParsedType current = type;
|
||||||
|
while (current != null && !current.name().equals(rootClassName)) {
|
||||||
|
names.addFirst(current.name());
|
||||||
|
current = current.parentName() == null ? null : typeByName.get(current.parentName());
|
||||||
|
}
|
||||||
|
return rootClassName + "." + String.join(".", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validatePackage(String value, String label) {
|
||||||
|
if (value == null || !PACKAGE_NAME_PATTERN.matcher(value).matches()) {
|
||||||
|
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateClassName(String value, String label) {
|
||||||
|
if (value == null || !CLASS_NAME_PATTERN.matcher(value).matches()) {
|
||||||
|
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String ownerType, String sourceName) {
|
||||||
|
return ownerType + "#" + sourceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJava(String value) {
|
||||||
|
return value == null ? "" : value.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ScanState { CODE, STRING, CHAR, LINE_COMMENT, BLOCK_COMMENT }
|
||||||
|
|
||||||
|
private record ClassRangeDraft(String name, int openBrace, int closeBrace) { }
|
||||||
|
private record ClassRange(String name, String parentName, int openBrace, int closeBrace) { }
|
||||||
|
private record FieldWithPosition(int position, ParsedField field) { }
|
||||||
|
}
|
||||||
@@ -451,6 +451,17 @@ public class PodScaffolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String applicationProfileYml(String profile, String port) {
|
private static String applicationProfileYml(String profile, String port) {
|
||||||
|
if ("local".equals(profile)) {
|
||||||
|
return """
|
||||||
|
spring:
|
||||||
|
config:
|
||||||
|
activate:
|
||||||
|
on-profile: local
|
||||||
|
import:
|
||||||
|
- classpath:glow/application-glow.yml
|
||||||
|
- classpath:glow/application-glow-local.yml
|
||||||
|
""";
|
||||||
|
}
|
||||||
return """
|
return """
|
||||||
server:
|
server:
|
||||||
port: ${PORT:%s}
|
port: ${PORT:%s}
|
||||||
@@ -477,8 +488,8 @@ public class PodScaffolder {
|
|||||||
List<String> targetModules)
|
List<String> targetModules)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
Map<String, Object> root = parseManifestYaml(source);
|
Map<String, Object> root = parseManifestYaml(source);
|
||||||
if (!(root.get("mcp") instanceof Map<?, ?> mcp)
|
Map<String, Object> manifest = manifestNode(root);
|
||||||
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
|
if (manifest == null) {
|
||||||
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
|
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +502,29 @@ public class PodScaffolder {
|
|||||||
routingFunction.put("server-id", moduleName);
|
routingFunction.put("server-id", moduleName);
|
||||||
routingFunction.put("category-key", categoryKey);
|
routingFunction.put("category-key", categoryKey);
|
||||||
routingFunction.put("confusable-servers", allowedTargetModules(rootDir, moduleName, targetModules));
|
routingFunction.put("confusable-servers", allowedTargetModules(rootDir, moduleName, targetModules));
|
||||||
((Map<String, Object>) manifest).put("routing-functions", List.of(routingFunction));
|
manifest.put("routing-functions", List.of(routingFunction));
|
||||||
return YAML_MAPPER.writeValueAsString(root);
|
return YAML_MAPPER.writeValueAsString(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<String, Object> manifestNode(Map<String, Object> root) {
|
||||||
|
if (root.get("mcp") instanceof Map<?, ?> mcp
|
||||||
|
&& mcp.get("manifest") instanceof Map<?, ?> manifest) {
|
||||||
|
return (Map<String, Object>) manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object routingFunctions = root.remove("mcp.manifest.routing-functions");
|
||||||
|
if (routingFunctions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> manifest = new java.util.LinkedHashMap<>();
|
||||||
|
manifest.put("routing-functions", routingFunctions);
|
||||||
|
Map<String, Object> mcp = new java.util.LinkedHashMap<>();
|
||||||
|
mcp.put("manifest", manifest);
|
||||||
|
root.put("mcp", mcp);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static Map<String, Object> routingFunction(Object routingFunctions) {
|
private static Map<String, Object> routingFunction(Object routingFunctions) {
|
||||||
if (routingFunctions instanceof List<?> functions
|
if (routingFunctions instanceof List<?> functions
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package io.shinhanlife.dat.lib.util;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class MciResponseScaffolderTest {
|
||||||
|
|
||||||
|
private static final String MCI_SOURCE = """
|
||||||
|
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
|
||||||
|
|
||||||
|
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ONBSZ0460_O {
|
||||||
|
@GlowTrgmField(order = 1, length = 20, description = "인사정보 목록")
|
||||||
|
private List<CmnnPrafIfinOutDto> employeeItems;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class CmnnPrafIfinOutDto {
|
||||||
|
@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 = 50, description = "주민등록번호")
|
||||||
|
private String rdreNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesGlowDescriptionsAndNestedResponseFields() {
|
||||||
|
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||||
|
|
||||||
|
assertEquals("io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io", parsed.packageName());
|
||||||
|
assertEquals("ONBSZ0460_O", parsed.rootClassName());
|
||||||
|
assertEquals(List.of("ONBSZ0460_O", "CmnnPrafIfinOutDto"),
|
||||||
|
parsed.types().stream().map(MciResponseScaffolder.ParsedType::name).toList());
|
||||||
|
assertEquals("인사번호", parsed.types().get(1).fields().getFirst().description());
|
||||||
|
assertEquals(8, parsed.types().get(1).fields().getFirst().length());
|
||||||
|
assertTrue(parsed.types().get(1).fields().get(3).sensitive());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatesLlmResponseAndExplicitMapStructMappings() {
|
||||||
|
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||||
|
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
|
||||||
|
new MciResponseScaffolder.FieldMapping("ONBSZ0460_O", "employeeItems", "employees", true),
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeTypeCode", true),
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNm", "employeeName", true),
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "rdreNo", "residentRegistrationNumber", true));
|
||||||
|
|
||||||
|
MciResponseScaffolder.GeneratedSources generated = MciResponseScaffolder.generate(
|
||||||
|
parsed,
|
||||||
|
"io.shinhanlife.dat.mcc.biz.pro.dto",
|
||||||
|
"IndividualCustomerDetailInquiryResponse",
|
||||||
|
"io.shinhanlife.dat.mcc.biz.pro.converter",
|
||||||
|
"IndividualCustomerDetailInquiryConverter",
|
||||||
|
mappings);
|
||||||
|
|
||||||
|
assertTrue(generated.responseSource().contains("@Schema(description = \"인사번호\")"));
|
||||||
|
assertTrue(generated.responseSource().contains("private String employeeNumber;"));
|
||||||
|
assertTrue(generated.responseSource().contains("private List<CmnnPrafIfinOutDto> employees;"));
|
||||||
|
assertTrue(generated.responseSource().contains("public static class CmnnPrafIfinOutDto"));
|
||||||
|
assertTrue(generated.converterSource().contains(
|
||||||
|
"@Mapping(source = \"prafNo\", target = \"employeeNumber\")"));
|
||||||
|
assertTrue(generated.converterSource().contains(
|
||||||
|
"IndividualCustomerDetailInquiryResponse toResponse(ONBSZ0460_O source);"));
|
||||||
|
assertTrue(generated.converterSource().contains(
|
||||||
|
"IndividualCustomerDetailInquiryResponse.CmnnPrafIfinOutDto toCmnnPrafIfinOutDto(ONBSZ0460_O.CmnnPrafIfinOutDto source);"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateAiTargetNamesWithinTheSameType() {
|
||||||
|
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
|
||||||
|
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
|
||||||
|
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeNumber", true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
MciResponseScaffolder.generate(parsed, "io.example.dto", "EmployeeResponse",
|
||||||
|
"io.example.converter", "EmployeeConverter", mappings);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
assertTrue(exception.getMessage().contains("중복"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Duplicate target names must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ class PodScaffolderTest {
|
|||||||
assertTrue(Files.exists(resources.resolve("application-prod.yml")));
|
assertTrue(Files.exists(resources.resolve("application-prod.yml")));
|
||||||
assertTrue(Files.readString(resources.resolve("application-local.yml"))
|
assertTrue(Files.readString(resources.resolve("application-local.yml"))
|
||||||
.contains("classpath:glow/application-glow-local.yml"));
|
.contains("classpath:glow/application-glow-local.yml"));
|
||||||
assertTrue(Files.readString(resources.resolve("application-local.yml"))
|
assertFalse(Files.readString(resources.resolve("application-local.yml"))
|
||||||
.contains("classpath:application-core-local.yml"));
|
.contains("application-core-local.yml"));
|
||||||
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
||||||
.contains("classpath:glow/application-glow-dev.yml"));
|
.contains("classpath:glow/application-glow-dev.yml"));
|
||||||
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
|
||||||
@@ -65,7 +65,7 @@ class PodScaffolderTest {
|
|||||||
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.02");
|
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.02");
|
||||||
|
|
||||||
Path resources = root.resolve("dat-was-cla/src/main/resources");
|
Path resources = root.resolve("dat-was-cla/src/main/resources");
|
||||||
for (String profile : List.of("local", "dev", "test", "prod")) {
|
for (String profile : List.of("dev", "test", "prod")) {
|
||||||
String content = Files.readString(resources.resolve("application-" + profile + ".yml"));
|
String content = Files.readString(resources.resolve("application-" + profile + ".yml"));
|
||||||
assertTrue(content.contains("port: ${PORT:8087}"));
|
assertTrue(content.contains("port: ${PORT:8087}"));
|
||||||
assertTrue(content.contains("on-profile: " + profile));
|
assertTrue(content.contains("on-profile: " + profile));
|
||||||
@@ -78,6 +78,23 @@ class PodScaffolderTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatesLocalProfileWithOnlyGlowImports() throws Exception {
|
||||||
|
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.03");
|
||||||
|
|
||||||
|
String localProfile = Files.readString(root.resolve("dat-was-cla/src/main/resources/application-local.yml"));
|
||||||
|
|
||||||
|
assertEquals("""
|
||||||
|
spring:
|
||||||
|
config:
|
||||||
|
activate:
|
||||||
|
on-profile: local
|
||||||
|
import:
|
||||||
|
- classpath:glow/application-glow.yml
|
||||||
|
- classpath:glow/application-glow-local.yml
|
||||||
|
""", localProfile);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception {
|
void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception {
|
||||||
Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
|
Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
|
||||||
@@ -137,6 +154,25 @@ class PodScaffolderTest {
|
|||||||
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
|
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesAiManifestWithDottedRoutingFunctionsRoot() throws Exception {
|
||||||
|
String aiManifest = """
|
||||||
|
mcp.manifest.routing-functions:
|
||||||
|
- name: route_to_dat-was-ehr
|
||||||
|
server-id: dat-was-ehr
|
||||||
|
category-key: ehr
|
||||||
|
business-outcome: "인사 정보와 휴가 정보를 제공합니다."
|
||||||
|
confusable-servers: [dat-was-hrd, dat-was-pay]
|
||||||
|
""";
|
||||||
|
|
||||||
|
String normalized = PodScaffolder.normalizeToolServiceManifest(aiManifest, "dat-was-ehr",
|
||||||
|
List.of("dat-was-cus", "dat-was-ehr", "dat-was-sal"));
|
||||||
|
Map<String, Object> routingFunction = routingFunction(normalized);
|
||||||
|
|
||||||
|
assertEquals("route_to_dat-was-ehr", routingFunction.get("name"));
|
||||||
|
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
|
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
|
||||||
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
|
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user